/* Additions on top of Webflow's stylesheet, which stays untouched.
   This is the only place where the site deviates from the original Webflow design. */

/* --- Works tabs -----------------------------------------------------------
   The tabs existed in Webflow but were hidden. They now split product design
   work (UX/UI) from websites (Web). */


/* Panes take over the stacking the works section used to do, so cards keep their rhythm. */
.w-tab-pane.w--tab-active {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 32px;
}

@media screen and (max-width: 479px) {
  .w-tab-pane.w--tab-active {
    gap: 24px;
  }
}

/* The pill slides in colour rather than snapping, and the tab answers a press. */
.tab {
  transition: color 200ms cubic-bezier(0.455, 0.03, 0.515, 0.955),
    background-color 180ms cubic-bezier(0.23, 1, 0.32, 1),
    box-shadow 180ms cubic-bezier(0.23, 1, 0.32, 1),
    transform 160ms cubic-bezier(0.23, 1, 0.32, 1);
}

.tab:active {
  transform: scale(0.97);
}

@media (prefers-reduced-motion: reduce) {
  .tab {
    transition-property: color, background-color, box-shadow;
  }

  .tab:active {
    transform: none;
  }
}

/* --- ASCII ripple ---------------------------------------------------------
   A monospace glyph field behind the hero. The script lifts it above the hero
   with an inline inset, so the headline sits on the field's falloff rather
   than on its bright middle; it carries no overlay or halo and has to stay
   readable without one. */
.ascii-ripple {
  position: absolute;
  inset: 0;
  z-index: 0;
  width: 100%;
  height: 100%;
  pointer-events: none;
  user-select: none;
}

/* Max: "on mobile maybe like this the bg?" — an arc drawn across the field
   just above the eyebrow.
   The problem is real: a phone is a third of the width, so the falloff that
   clears the type at 1440 has not happened yet at 390 and the type lands in
   the live field. Measured against the rendered pixels behind each glyph in
   light at 390, the eyebrow was 1.51:1 and the headline 3.83:1, against 8.17
   and 16.31 for the same two at 1440.
   The first fix masked the field away above the type, and Max's answer to it
   was "that's bad overlay, pls remove it — maybe just move the animated bg a
   bit higher on mobile." He is right, and the reason is worth keeping: a mask
   ENDS the picture at a line, and the eye finds that line however soft the
   ramp is. Moving the field up instead changes nothing about the picture, it
   just puts the part that was always meant to sit behind the type — the
   falloff — where the type actually is. It is the same move the desktop hero
   already makes; a phone simply needs more of it.
   A translate rather than a taller canvas, because the shader reads its own
   resolution: growing the box restretches the field and the horizon lands
   somewhere else again. Translating moves the picture and leaves it alone.
   160px, measured across 320-767: it takes the light eyebrow from 1.51:1 to
   8.38:1 and the headline from 3.83:1 to 18.57:1, where 110px only reaches
   4.88:1 on the eyebrow — over the line but with nothing spare. Both themes,
   one rule: dark gains little (5.40 to 5.90) but it costs dark nothing, and
   the two themes having the same field in the same place is worth more than
   the 0.5 it saves.
   Nothing is left uncovered at the bottom: the field's last rows are the page
   colour in both themes, which is what the gap it opens is filled with.
   Not the 404 — Max settled that page as it stands. */
@media screen and (max-width: 767px) {
  .hero-section:not(.not-found-section) .ascii-ripple {
    --ga-lift: 160px;
    transform: translateY(calc(var(--ga-lift) * -1));
  }
}

.hero-section {
  overflow: hidden;
}

.hero-container {
  z-index: 1;
}

.hero-section .hero-div {
  position: relative;
}

/* Reduced motion keeps the resting field and drops the animation entirely;
   the script also stops listening for the pointer. */

/* Max picked this one. macOS subpixel antialiasing inks every glyph heavier
   and fringes its stems, and the site opens in dark by default, which is where
   white-on-#121212 over-inking shows most — the type was reading about a half
   weight bolder than Inter and Unbounded are drawn. It inherits, so the root
   is enough; body is here only so a stray reset cannot undo it. */
html,
body {
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
}


/* --- Palette --------------------------------------------------------------
   One neutral ramp, inverted for dark. Webflow's own --black and --light-grey
   are redefined here so its existing rules follow the theme without being
   touched; everything else hangs off the tokens below.
   The greys are deliberately neutral — the original #edeff1 and #15161c both
   carried a blue cast. */
:root {
  color-scheme: light;

  /* The card and row cast, one token per theme — see [data-theme='dark']. */
  --card-cast:
    0 1px 2px rgba(3, 17, 28, 0.04),
    0 12px 28px -18px rgba(3, 17, 28, 0.25);
  --card-cast-hover:
    0 2px 4px rgba(3, 17, 28, 0.04),
    0 18px 36px -24px rgba(3, 17, 28, 0.26);

  --page: #ffffff;
  --surface: #f5f5f5;
  --surface-2: #f0f0f0;
  --line: rgba(3, 17, 28, 0.1);
  --ink: #03111c;
  /* Max: "the color of text is too grey". Running prose was on --muted, which
     is the token for secondary labels — correct for a date stamp, too quiet
     for three paragraphs of reading. --body sits between it and the ink: still
     clearly not a heading, but no longer greyed out. Meta (--muted) and titles
     (--ink) are unchanged, so the three levels stay distinct. */
  --body: rgba(3, 17, 28, 0.88);
  --muted: rgba(3, 17, 28, 0.75);
  --faint: rgba(3, 17, 28, 0.62);
  --accent-ink: #03111c;
  --blue: #03111c;
  /* Gradient ASCII (ported component) — colours and feel per theme. */
  --ga-colors: #1e3fd8, #f6f8ff, #a5d8ff, #38bdf8;
  --select-bg: #1e3fd8;

  /* One column width for the whole site. The home cards, the footer, the case
     studies and the about page all sit on it, so nothing steps in or out as
     you scroll from one page to the next. Stored once. */
  /* How far every floating thing sits from the edge of the window: both
     headers from the top, the dock from the bottom. Stored once so the two
     ends of the page cannot drift apart. */
  --float-inset: 16px;

  --column: 900px;
  --column-gutter: 32px;
  /* The seat around a case-study shot. Stored once because the slider's
     arrows are centred in that same gutter — two copies of 40px and the
     arrows drift off centre the first time the seat changes. */
  --shot-pad: 40px;
  /* Max: "imgs and demo can be wider pls." The reading column and the media
     column are two different jobs. --column is a measure — how long a line of
     prose may run before it gets hard to track back to — and widening it to
     fit a screenshot would cost the thing it exists to protect. --media is how
     much room a picture gets, and a picture has no measure. The section's own
     64px side padding caps this at 1312 on a 1440 window, and the live demo
     upscales past 1280, so 1120 clears both. Below 768 they collapse to the
     same thing, because there is no room to break out of. */
  --media: 1120px;

  /* One gap between any two blocks of a case study, owned here rather than
     written as a margin-top on each component. It was five numbers before:
     56 after the demo, 0 after an image panel, 0 after the slider, 56 on a
     shot, 32 on a bullet box — so the same relationship, a wide visual
     followed by a paragraph, was drawn three different ways on one page. */
  --block-gap: 56px;

  /* Glass is the house material. Every chip, button and bar is made of it, so
     the recipe lives here once rather than being retyped per component.
     The hairline PAIR is the whole trick: a bright line along the top edge and
     a dark one along the bottom. Without it a translucent box is just a
     translucent box; with it, it reads as a pane with thickness. */
  /* Max: "I meant glass buttons and those glass shapes a bit more greyish."
     The tint was mixed from --page, which on a white page resolves to white —
     a pane with no body of its own, visible only where it happened to cross
     something darker. The availability chip is the clearest case: its left half
     sat on the white page and simply was not there, while its right half read
     fine over the card artwork. The tint now starts from its own grey, so the
     pane has a body everywhere. One number dials the whole material; the
     hairline stays on --page because a highlight is meant to be the brightest
     thing on the pane. Dark needs none of this — a white lift on a near-black
     page already has a body — so it sets its glass literally below. */
  --glass-tint: #e8e8e8;
  --glass-bg: color-mix(in srgb, var(--glass-tint) 62%, transparent);
  --glass-bg-strong: color-mix(in srgb, var(--glass-tint) 78%, transparent);
  --glass-hi: color-mix(in srgb, var(--page) 95%, transparent);
  --glass-lo: color-mix(in srgb, var(--ink) 8%, transparent);
  --glass-cast: 0 1px 2px rgba(3, 17, 28, 0.1), 0 10px 24px -12px rgba(3, 17, 28, 0.4);
  --glass-blur: blur(14px) saturate(1.6);
  /* How far the dock pushes its backdrop toward the page before the tint
     lands. Light theme brightens, dark theme darkens. */
  --dock-floor: 1.5;
  --ga-shape: horizon;
  --ga-glyph-opacity: 0.8;
  --ga-gradient-opacity: 0.8;


  /* The ripple reads its colours from here, so it follows the theme. */
  --ripple-ink: 3, 17, 28;
  --ripple-crest: 17, 111, 248;
  --ripple-trough: 120, 120, 132;
  --ripple-alpha: 0.062;

  /* The gradient-ASCII field reads its four stops from here, floor to core: one
     number per cell walks c1 to c4, so the glyph and its colour come from the same
     value. --ga-alpha is the core's opacity, --ga-floor the resting ink of the
     lattice away from the glow. Same job as --ripple-*, same reason. */

  /* Webflow's variables, neutralised. */
  --black: #121212;
  --light-grey: #ffffff;
}

[data-theme='dark'] {
  color-scheme: dark;

  /* A cast is drawn in the theme it appears in. Mixed from the light theme's
     ink it is very nearly nothing over a #121212 page, so in dark the cards
     and the rows had no lift at all — just an 8% hairline. One token per
     theme, because the cast was written out four separate times in light ink,
     and a dark override that restated it wiped the phone card's inset edge
     along with it. */
  --card-cast:
    0 1px 2px rgba(0, 0, 0, 0.6),
    0 12px 28px -18px rgba(0, 0, 0, 0.95);
  --card-cast-hover:
    0 2px 4px rgba(0, 0, 0, 0.6),
    0 18px 36px -24px rgba(0, 0, 0, 0.95);

  --page: #121212;
  --surface: #1f1f1f;
  --surface-2: #171717;
  --line: rgba(255, 255, 255, 0.08);
  --ink: #ffffff;
  --body: rgba(240, 240, 240, 0.92);
  --muted: rgba(202, 202, 202, 0.7);
  --faint: rgba(255, 255, 255, 0.38);
  --accent-ink: #ffffff;
  --blue: #ffffff;
  --ga-colors: #2a4bff, #0b1020, #6ea8ff, #38bdf8;
  --select-bg: #2a4bff;

  /* On a dark page the pane is lit from the front rather than tinted from the
     page, so the bright hairline carries most of the read. */
  --glass-bg: rgba(255, 255, 255, 0.14);
  --glass-bg-strong: rgba(255, 255, 255, 0.2);
  --glass-hi: rgba(255, 255, 255, 0.3);
  --glass-lo: rgba(0, 0, 0, 0.3);
  --glass-cast: 0 10px 24px -12px rgba(0, 0, 0, 0.7);
  --glass-blur: blur(14px) saturate(1.6);
  --dock-floor: 0.45;
  --ga-glyph-opacity: 0.8;
  --ga-gradient-opacity: 0.8;


  --ripple-ink: 235, 235, 235;
  --ripple-crest: 158, 158, 255;
  --ripple-trough: 140, 140, 160;
  --ripple-alpha: 0.075;

  /* Inverted: on a dark page a glow peaks in light, not in saturation, so the core
     stop goes near-white and the floor is the muted grey. */

  --black: #ffffff;
  --light-grey: #121212;
}

/* Surfaces that Webflow hard-coded, re-pointed at the tokens. */
.case-study-div,
.card---brix {
  background-color: var(--surface);
  border-color: var(--line);
}

.footer,
.tab-link-div._40px-bottom-margin,
.client-linkedin {
  background-color: var(--surface-2);
}

.arrow-button {
  background-color: var(--surface-2);
}

.label,
.label-large {
  color: var(--muted);
}

.link-2 {
  color: var(--muted);
}

.divider-line.dark {
  background-color: var(--line);
}

.highlighted-container,
.menu-link:hover,
.menu-dropdown {
  background-color: var(--surface);
}

.tab.w--current {
  background-color: var(--page);
  color: var(--accent-ink);
}

.button-small,
.button-text,
.link:hover,
.menu-link.w--current {
  color: var(--accent-ink);
}

/* A quarter of the side, as a ratio, in both themes. This was the one place on
   the site storing a corner as an absolute — and it was set in dark only, so
   the same tile had a 12px corner in dark and whatever its artwork happened to
   carry in light (10px on the two SVGs). A percentage is a true quarter at
   every size: 12px at 48 and 10px at 40. It also clips about 1.3px off the
   cooked PNG's own plate corner, which is the point — it brings the raster
   tile onto the same quarter as the vector marks beside it. */
[data-theme] .icon-logo {
  border-radius: 25%;
}

/* The Kyrrex mark is the one logo that is both near-white and achromatic, so on
   a white page its plate simply is not there: four work entries show a 48px
   rounded tile and the fifth shows a green glyph floating loose on the card.
   Same thing inline in the About copy. This is the site's own image-edge
   recipe, copied from .slider-slide img and .image-container-works img down to
   the negative offset, which keeps the hairline inside the tile's own corner
   instead of drawing a square around it. */
.icon-logo,
.inline-mark {
  outline: 1px solid rgba(0, 0, 0, 0.1);
  outline-offset: -1px;
}

[data-theme='dark'] .icon-logo,
[data-theme='dark'] .inline-mark {
  outline-color: rgba(255, 255, 255, 0.1);
}

/* --- Work card artwork ----------------------------------------------------
   The screenshot sits on a soft tinted panel instead of straight on the card,
   so each piece of work reads as its own object. Per-card tints are set
   inline on the card; these are the fallbacks. */
.image-column {
  /* The artwork half: a gradient wrapper, 32px of it showing along the top and
     left, then the screenshot running off the right and bottom edges. The box
     is identical on every card, so every card has the same structure and the
     shots line up down the page. */
  position: relative;
  box-sizing: border-box;
  display: block;
  width: 100%;
  height: 100%;
  height: 420px;
  min-height: 420px;
  padding: 32px 0 0 32px;
  overflow: hidden;
  background:
    radial-gradient(130% 120% at 8% 0%, var(--shot-glow, rgba(255, 255, 255, 0.9)) 0%, transparent 58%),
    linear-gradient(135deg, var(--shot-a, #f3f3f3) 0%, var(--shot-b, #e8e8e8) 100%);
}

.case-study-div {
  overflow: hidden;
}

/* Both halves are pinned to the same height so a longer title cannot stretch
   one card out of line with the rest — all cards share one structure. */
[data-theme] .left-column {
  height: 420px;
  min-height: 420px;
}

/* The case-study headline. `.heading-3` is this and nothing else — all five
   case studies, no other page — so it can be addressed directly.

   Max: "on this page title can be one line but keep capitalized pls." Two
   things were stopping that. Webflow caps it at 560px inside a 900px text
   column, so it wrapped with a third of the measure unused; and unlike
   `.heading-1`, `.heading-2` and the card's `.text-head`, it carried no
   transform, so it set in the sentence case the source is written in while
   the card above it set in title case. Both fixed here, which keeps one place
   deciding the case for every headline on the site.

   The cap comes off at 768, which is where the text column stops being 900px
   wide: below it the column is the constraint — 520px at 600 and 358px at 390
   — so there is no one-line to be had and Webflow's own cap does no harm. */
[data-theme] .heading-3 {
  text-transform: capitalize;
}

@media screen and (min-width: 768px) {
  [data-theme] .heading-3 {
    max-width: none;
  }
}


/* Max: "can we make sure titles fit in 2 lines pls." Webflow pads this half by
   56px, which inside a 449px grid column leaves the title 337px of measure —
   and at 1440 that is 12px short for "Creator flow experience for Limitless"
   and 4px short for "Crypto-to-fiat payment solution for Kyrrex". Every other
   width was already fine: the column is 369px at 991 and 470px at 600, where
   Webflow's own padding steps down. 44px brings the measure to 361px, which is
   12px clear of the worst title rather than on its edge. Only above 992 —
   below that Webflow's 40px and 24px already leave room. */
@media screen and (min-width: 992px) {
  [data-theme] .left-column {
    padding: 44px;
  }
}

.image-column img {
  display: block;
  width: 100%;
  height: 100%;
  object-fit: cover;
  object-position: left top;
  /* Only the corner that is actually visible gets a radius. */
  border-radius: 12px 0 0 0;
  /* drop-shadow, not box-shadow: three of the four shots are transparent PNGs
     of phones and windows, and a box-shadow draws the element's RECTANGLE —
     an empty rounded frame floating on the panel around artwork that does not
     fill it. drop-shadow follows the alpha, so each shape casts its own. */
  /* The cast is a variable so the dark theme only has to swap the one piece
     that differs rather than restate the whole list. */
  --shot-cast:
    drop-shadow(0 1px 1px rgba(3, 17, 28, 0.08))
    drop-shadow(0 18px 20px rgba(3, 17, 28, 0.3));
  filter: var(--shot-cast);
  transition: transform 700ms cubic-bezier(0.23, 1, 0.32, 1);
}

@media (hover: hover) and (pointer: fine) {
  /* The shot used to drift up and left on hover. Moving left opened a strip of
     gradient down the right edge where it had been bleeding off the card, and
     moving up walked its top corners into the card's 24px radius — both read
     as the image being cut. The card's own 2% scale is the hover feedback; the
     shot stays where it is. */
  .case-study-div:hover .image-column img {
    transform: none;
  }
}

[data-theme='dark'] .image-column {
  background:
    radial-gradient(130% 120% at 8% 0%, var(--shot-glow-dark, rgba(255, 255, 255, 0.08)) 0%, transparent 58%),
    linear-gradient(135deg, var(--shot-a-dark, #232323) 0%, var(--shot-b-dark, #161616) 100%);
}

[data-theme='dark'] .image-column img {
  --shot-cast:
    drop-shadow(0 1px 1px rgba(0, 0, 0, 0.55))
    drop-shadow(0 18px 20px rgba(0, 0, 0, 0.55));
}

/* Two columns only while two columns fit; below that, stacked.

   This used to have FOUR breakpoints for a box with one job, and the middle
   two were where it fell apart. At 480-767 the card stayed two-column but the
   height contract that makes two columns work was thrown away: the text half
   was released to grow and the artwork half became a function of its own
   width, so the artwork turned into a short banner pinned to the top-right
   corner with up to 398px of empty card under it — measured. On top of that
   `1fr 1fr` does not hold at those widths, because `1fr` is
   `minmax(auto, 1fr)` and the text half cannot shrink past its own
   min-content: at 480 the four cards split 234/164, 227/171, 218/180 and
   216/182, so no two artwork panels down the page were the same width. That is
   Max's "the right side should always have same width as card".

   Stacked, the artwork IS the full width of the card, every card agrees with
   every other, the headline gets the whole width instead of 136px, and the tag
   row stops wrapping. One rule, one shape. */
@media screen and (min-width: 768px) and (max-width: 991px) {
  .image-column,
  [data-theme] .left-column {
    /* min-height, not height. Pinned at 340 the longest stat label was being
       cut through its last line by the card's own overflow: hidden — 33px of
       it on the Knights of Degen card, measured. */
    height: auto;
    min-height: 340px;
  }

  [data-theme] .case-study-div {
    align-items: stretch;
  }

  .image-column {
    padding: 24px 0 0 24px;
  }
}

@media screen and (max-width: 767px) {
  [data-theme] .case-study-div {
    grid-template-rows: auto auto;
    grid-template-columns: 1fr;
  }

  [data-theme] .left-column {
    height: auto;
    min-height: 0;
    padding: 24px;
  }

  .image-column {
    width: 100%;
    min-height: 0;
    height: auto;
    /* One panel shape below 768 rather than three. The same screenshot was
       being cropped three different ways by three different aspect ratios. */
    aspect-ratio: 5 / 4;
    padding: 24px 0 0 24px;
  }
}

@media (prefers-reduced-motion: reduce) {
  .image-column img,
  .case-study-div:hover .image-column img {
    transition: none;
    transform: none;
    scale: 1;
  }

  .case-more {
    transition: none;
  }
}

/* --- Contact dock ---------------------------------------------------------
   A black pill plus free-standing circles, as in the reference — not one glass
   container holding everything. The pill carries the one action that matters;
   the circles are secondary and look it.

   Icons follow the Limitless share card, which is the house precedent: Lucide
   outline at stroke-width 1.8 for the UI marks, brand marks as solid fills.
   The X is the same drawing that card uses, lifted verbatim. Solid brand marks
   next to outline UI icons is the share card's own look, not an oversight. */
.sr-only {
  position: absolute;
  width: 1px;
  height: 1px;
  padding: 0;
  margin: -1px;
  overflow: hidden;
  clip: rect(0 0 0 0);
  white-space: nowrap;
  border: 0;
}

.dock {
  position: fixed;
  z-index: 60;
  /* Max, twice: "a bit lower pls", then "should be a lil lower on the page".
     24 -> 16 -> 10. */
  bottom: max(var(--float-inset), env(safe-area-inset-bottom));
  left: 50%;
  display: flex;
  align-items: center;
  gap: 10px;
  /* --dock-lift is written by dock.js once the footer is close enough to catch
     the dock; it is what makes the bar come to rest above the footer instead of
     floating over it. The entrance animation is removed when it ends, or its
     fill-mode would keep overriding this transform. */
  transform: translateX(-50%) translateY(calc(-1 * var(--dock-lift, 0px)));
}

.dock-cta {
  display: inline-flex;
  align-items: center;
  gap: 9px;
  height: 56px;
  padding: 0 26px 0 22px;
  border-radius: 999px;
  background: var(--ink);
  color: var(--page);
  font-size: 15px;
  font-weight: 500;
  letter-spacing: -0.01em;
  text-decoration: none;
  /* House rule: display case comes from CSS, so the source stays sentence case. */
  text-transform: capitalize;
  white-space: nowrap;
  box-shadow: 0 2px 6px -2px rgba(3, 17, 28, 0.3), 0 16px 32px -14px rgba(3, 17, 28, 0.45);
  transition:
    transform 180ms cubic-bezier(0.23, 1, 0.32, 1),
    box-shadow 180ms cubic-bezier(0.23, 1, 0.32, 1);
}

.dock-cta svg {
  width: 20px;
  height: 20px;
  flex: none;
}

.dock-cta:active {
  transform: scale(0.96);
}

.dock-btn {
  position: relative;
  display: grid;
  place-items: center;
  width: 56px;
  height: 56px;
  padding: 0;
  border: 0;
  border-radius: 999px;
  background: var(--glass-bg);
  color: var(--ink);
  cursor: pointer;
  -webkit-tap-highlight-color: transparent;
  backdrop-filter: var(--glass-blur);
  -webkit-backdrop-filter: var(--glass-blur);
  box-shadow:
    inset 0 1px 0 var(--glass-hi),
    inset 0 -1px 0 var(--glass-lo),
    var(--glass-cast);
  transition:
    background-color 180ms cubic-bezier(0.23, 1, 0.32, 1),
    transform 180ms cubic-bezier(0.23, 1, 0.32, 1);
}

.dock-btn svg {
  width: 21px;
  height: 21px;
  transition: transform 220ms cubic-bezier(0.23, 1, 0.32, 1);
}

@media (hover: hover) and (pointer: fine) {
  .dock-btn:hover {
    background: color-mix(in srgb, var(--ink) 10%, var(--surface));
  }

  .dock-btn:hover svg {
    transform: translateY(-1px);
  }
}

.dock-btn:focus-visible,
.dock-cta:focus-visible {
  outline: 2px solid var(--accent-ink);
  outline-offset: 3px;
}

.dock-btn:active {
  transform: scale(0.92);
}

.dock-btn .icon-moon {
  display: none;
}

[data-theme='dark'] .dock-btn .icon-sun {
  display: none;
}

[data-theme='dark'] .dock-btn .icon-moon {
  display: block;
}

/* One tooltip travels between the circles rather than each owning its own,
   so the label slides instead of blinking out and back. */
.dock-tip {
  position: absolute;
  bottom: calc(100% + 12px);
  left: 0;
  padding: 6px 11px;
  border-radius: 9px;
  background: var(--ink);
  color: var(--page);
  /* Max: "for tooltips let's use regular font pls." Unbounded is the display
     face — it carries headlines and the one chip that is a statement; a label
     that appears under your cursor is reading text. Stated rather than
     inherited: both tips sit inside a control that is itself Unbounded, so
     dropping the declaration left them exactly where they were. */
  font-family: Inter, sans-serif;
  font-size: 13px;
  line-height: 18px;
  font-weight: 500;
  letter-spacing: -0.005em;
  white-space: nowrap;
  pointer-events: none;
  opacity: 0;
  transform: translateX(var(--tip-x, 0)) translateY(5px) scale(0.92);
  transform-origin: bottom center;
  transition:
    opacity 150ms cubic-bezier(0.23, 1, 0.32, 1),
    transform 150ms cubic-bezier(0.23, 1, 0.32, 1);
}

.dock-tip[data-shown] {
  opacity: 1;
  transform: translateX(var(--tip-x, 0)) translateY(0) scale(1);
}

.dock-tip[data-warm] {
  transition:
    opacity 90ms linear,
    transform 240ms cubic-bezier(0.23, 1, 0.32, 1);
}

@media screen and (max-width: 560px) {
  .dock {
    gap: 8px;
  }

  .dock-cta {
    height: 50px;
    padding: 0 20px 0 17px;
    font-size: 14px;
  }

  .dock-btn {
    width: 50px;
    height: 50px;
  }
}

/* Max: "on mobile = keep full button contact me." The label used to go at
   400px and the pill became a 50px circle, which is what you get on a 390
   screen — the one width he actually looks at.

   Measured: with the label in, the dock is 341px against the 358px a 390
   screen leaves inside --float-inset, so it already fits there and at 375.
   It does not fit at 360, a common Android width, so the circles and the gap
   come down a notch rather than the label coming out — that buys 25px and
   carries the full pill to about 352. Below that there is genuinely no room
   and the pill still collapses, which is the old behaviour kept as a floor
   rather than as the rule. */
@media screen and (max-width: 400px) {
  .dock {
    gap: 6px;
  }

  .dock-btn {
    width: 46px;
    height: 46px;
  }

  .dock-cta {
    height: 46px;
    padding: 0 16px 0 14px;
  }
}

@media screen and (max-width: 351px) {
  .dock-cta span {
    display: none;
  }

  .dock-cta {
    width: 46px;
    padding: 0;
    justify-content: center;
  }
}

@media (prefers-reduced-motion: reduce) {
  .dock {
    animation: none;
  }

  .dock-cta,
  .dock-btn,
  .dock-btn svg,
  .dock-tip,
  /* Named explicitly: .dock-tip[data-warm] re-declares the whole shorthand at
     0-2-0, and a media query adds no specificity, so a bare .dock-tip loses. */
  .dock-tip[data-warm] {
    transition: none;
  }

  .dock-cta:active,
  .dock-btn:active {
    transform: none;
  }
}

/* --- Edge scrims ----------------------------------------------------------
   Ported from Max's own simple-platform prototype, where the technique and
   its reasoning were worked out: six backdrop layers whose blur roughly
   doubles — each mask starting fully transparent
   and overlapping its neighbour by half its span, so at any height two
   layers are mid-fade and the ramp has no step in it. The first layer is
   sub-pixel on purpose: backdrop-filter only samples inside its own box, so
   at the very first row the blur clamps, and a clamped 1px blur across a
   high-contrast edge is a line you can see.

   Max: "less blur on top for case study page pls." The ladder is one token
   now (--scrim-blur, the top of it) and every layer is a fraction of it, so
   the doubling survives a change of strength. It was 24 at the top; a case
   study opens on a full-bleed cover and the scrim was smearing the first
   inch of the picture under the header. 10 keeps the edge soft without
   taking the top of the shot with it.

   --pb-dir flips the whole ramp, so the top and bottom scrims are provably
   the same effect pointed opposite ways rather than two stacks that drift. */
.scrim {
  --scrim-blur: 10px;
  position: fixed;
  left: 0;
  right: 0;
  z-index: 50;
  height: 150px;
  pointer-events: none;
  /* --scrim-p is 0 to 1, written by dock.js from the scroll position, so the
     blur arrives in step with the scroll rather than switching on once past a
     threshold. The transition is only there to smooth the gap between rAF
     frames; the scroll is what drives it. */
  opacity: var(--scrim-p, 0);
  transition: opacity 90ms linear;
}

.scrim-top {
  top: 0;
  /* The mask runs bottom-to-top, so the heaviest layers land on the very edge
     and the ramp reaches nothing by the scrim's inner side: full blur where
     the page is cut off, clear where the content begins. */
  --pb-dir: to top;
  /* The tint points the other way, strongest at that same edge. */
  --scrim-dir: to bottom;
}


.scrim > span {
  position: absolute;
  inset: 0;
  pointer-events: none;
}

.scrim > span:nth-child(1) {
  -webkit-backdrop-filter: blur(calc(var(--scrim-blur) * 0.025));
  backdrop-filter: blur(calc(var(--scrim-blur) * 0.025));
  -webkit-mask-image: linear-gradient(var(--pb-dir, to bottom), transparent 0%, black 26%, black 100%);
  mask-image: linear-gradient(var(--pb-dir, to bottom), transparent 0%, black 26%, black 100%);
}

.scrim > span:nth-child(2) {
  -webkit-backdrop-filter: blur(calc(var(--scrim-blur) * 0.0625));
  backdrop-filter: blur(calc(var(--scrim-blur) * 0.0625));
  -webkit-mask-image: linear-gradient(var(--pb-dir, to bottom), transparent 13%, black 40%, black 100%);
  mask-image: linear-gradient(var(--pb-dir, to bottom), transparent 13%, black 40%, black 100%);
}

.scrim > span:nth-child(3) {
  -webkit-backdrop-filter: blur(calc(var(--scrim-blur) * 0.125));
  backdrop-filter: blur(calc(var(--scrim-blur) * 0.125));
  -webkit-mask-image: linear-gradient(var(--pb-dir, to bottom), transparent 26%, black 54%, black 100%);
  mask-image: linear-gradient(var(--pb-dir, to bottom), transparent 26%, black 54%, black 100%);
}

.scrim > span:nth-child(4) {
  -webkit-backdrop-filter: blur(calc(var(--scrim-blur) * 0.25));
  backdrop-filter: blur(calc(var(--scrim-blur) * 0.25));
  -webkit-mask-image: linear-gradient(var(--pb-dir, to bottom), transparent 40%, black 68%, black 100%);
  mask-image: linear-gradient(var(--pb-dir, to bottom), transparent 40%, black 68%, black 100%);
}

.scrim > span:nth-child(5) {
  -webkit-backdrop-filter: blur(calc(var(--scrim-blur) * 0.5));
  backdrop-filter: blur(calc(var(--scrim-blur) * 0.5));
  -webkit-mask-image: linear-gradient(var(--pb-dir, to bottom), transparent 54%, black 82%, black 100%);
  mask-image: linear-gradient(var(--pb-dir, to bottom), transparent 54%, black 82%, black 100%);
}

.scrim > span:nth-child(6) {
  -webkit-backdrop-filter: blur(calc(var(--scrim-blur) * 1));
  backdrop-filter: blur(calc(var(--scrim-blur) * 1));
  -webkit-mask-image: linear-gradient(var(--pb-dir, to bottom), transparent 68%, black 100%, black 100%);
  mask-image: linear-gradient(var(--pb-dir, to bottom), transparent 68%, black 100%, black 100%);
}

/* The tint over the layers: a veil, not a wash. Smoothstep (3t² − 2t³) so
   neither end of the ramp shows as a line. */
.scrim > span:last-child {
  backdrop-filter: none;
  -webkit-backdrop-filter: none;
  background-image: linear-gradient(
    var(--scrim-dir, to bottom),
    color-mix(in srgb, var(--page) 34%, transparent) 0%,
    color-mix(in srgb, var(--page) 33%, transparent) 10%,
    color-mix(in srgb, var(--page) 30.5%, transparent) 20%,
    color-mix(in srgb, var(--page) 26.7%, transparent) 30%,
    color-mix(in srgb, var(--page) 22%, transparent) 40%,
    color-mix(in srgb, var(--page) 17%, transparent) 50%,
    color-mix(in srgb, var(--page) 12%, transparent) 60%,
    color-mix(in srgb, var(--page) 7.3%, transparent) 70%,
    color-mix(in srgb, var(--page) 3.5%, transparent) 80%,
    color-mix(in srgb, var(--page) 0.95%, transparent) 90%,
    transparent 100%
  );
  -webkit-mask-image: none;
  mask-image: none;
}

@media screen and (max-width: 767px) {
  .scrim {
    height: 110px;
  }
}

/* Nothing here moves, so reduced motion keeps it — it is a scroll-position
   opacity, the same class of thing as a sticky header's shadow. */


/* --- Surfaces Webflow hard-coded to white ---------------------------------
   These stayed pure white in dark mode. The avatar and the burger were the
   worst of it: the top scrim blurs whatever is under it, so two white discs
   smeared into one bright blob across the header.
   Prefixed with [data-theme] purely for specificity — Webflow's own rules are
   two classes deep, so a single class here would lose. */
[data-theme] .ava,

[data-theme] .tab-link-div._40px-bottom-margin {
  background-color: var(--surface-2);
}

[data-theme] .tab {
  background-color: transparent;
  color: var(--muted);
}

[data-theme] .tab.w--current {
  background-color: var(--surface);
  color: var(--accent-ink);
}

[data-theme] .not-available-div {
  background-color: color-mix(in srgb, var(--ink) 7%, transparent);
  color: var(--muted);
}

[data-theme] .social-media-div {
  background-color: color-mix(in srgb, var(--ink) 8%, transparent);
  transition: background-color 180ms cubic-bezier(0.23, 1, 0.32, 1);
}

[data-theme] .social-media-div:hover {
  background-color: color-mix(in srgb, var(--ink) 14%, transparent);
}

/* The memoji and the social marks are dark artwork on a light disc; in dark
   mode the disc goes dark too, so they need lifting off it. */
[data-theme='dark'] .social-media-div img {
  filter: invert(1) hue-rotate(180deg);
  opacity: 0.86;
}


/* --- Audit fixes ----------------------------------------------------------
   From a multi-agent audit where every finding was then attacked by a skeptic.
   These are the ones that survived. */

/* Webflow's whole near-black text family was never re-pointed. In dark mode
   .paragraph composited to 1.03:1 — the About page prose rendered effectively
   blank. --muted measures 5.1:1 light and 5.6:1 dark. */
[data-theme] .text-uppercase,
[data-theme] .text-small,
[data-theme] .not-available-text {
  color: var(--muted);
}

/* Running prose, one level up from the meta above it. */
[data-theme] .paragraph,
[data-theme] .paragraph-small,
[data-theme] .paragraph-large,
[data-theme] .rich-text-block {
  color: var(--body);
}

/* The tab row had no focus ring at all, so a keyboard user could not see where
   they were even once the arrow keys worked. */
.tab:focus-visible,
.burger:focus-visible,
.menu-link:focus-visible,
.social-media-div:focus-visible,
.case-study-div:focus-visible {
  outline: 2px solid var(--accent-ink);
  outline-offset: 3px;
}

/* The burger is a real <button> now; keep it looking as it did. */

/* The biggest pressable things on the site answered a press with nothing.
   `scale` rather than `transform`, because the entrance engine writes
   el.style.transform inline on every card and never clears it — an inline
   transform would beat a :active transform, but it does not touch `scale`. */
.case-study-div,
.menu-link,
.social-media-div {
  transition: scale 160ms cubic-bezier(0.23, 1, 0.32, 1);
}

.case-study-div:active {
  scale: 0.996;
}

.menu-link:active,
.social-media-div:active {
  scale: 0.96;
}

/* The lit disc under a hovered dock button kept a 50% white top hairline in
   dark, where the dock's own rim sits at 14%. */
[data-theme='dark'] .dock-spot {
  box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.12);
}

/* A phone cannot pay for twelve serial backdrop blurs over a 72px band, and at
   that height the ramp is barely readable anyway. The tint alone still veils
   the edge, and costs nothing. */
@media screen and (max-width: 767px) {
  .scrim > span:not(:last-child) {
    backdrop-filter: none;
    -webkit-backdrop-filter: none;
  }
}

@media (prefers-reduced-motion: reduce) {
  .case-study-div,
  .menu-link,
  .social-media-div {
    transition: none;
  }

  .case-study-div:active,
  .menu-link:active,
  .social-media-div:active {
    scale: 1;
  }
}


/* --- Dark mode: the light-panel family -------------------------------------
   Found by walking every text node on every page in both themes and computing
   the real composited ratio, rather than checking selectors I suspected.
   Webflow paints a whole family of panels at #ffffffa3 / #fff6. They stayed
   light in dark mode while the text on them flipped to white, which measured
   1.08:1 — invisible. An earlier adversarial pass refuted this very finding;
   the harness proves it. */
[data-theme] .highlighted-container,
[data-theme] .info-block,
[data-theme] .cms-div,
[data-theme] .components-div,
[data-theme] .image-container-works,
[data-theme] .shot-slider,
[data-theme] .let-s-connect-grid,
[data-theme] .slide-div,
[data-theme] .message,
[data-theme] .back-arrow {
  background-color: var(--surface);
}

[data-theme='dark'] .highlighted-container,
[data-theme='dark'] .info-block,
[data-theme='dark'] .cms-div,
[data-theme='dark'] .components-div,
[data-theme='dark'] .image-container-works,
[data-theme='dark'] .shot-slider,
[data-theme='dark'] .let-s-connect-grid,
[data-theme='dark'] .slide-div,
[data-theme='dark'] .message {
  box-shadow: inset 0 0 0 1px var(--line);
}

/* Everything written on those panels. .heading-4 and .info-text were at
   2.33:1, the point lists and their <strong> runs at 1.08:1. */
[data-theme] .info-text,
[data-theme] .highlighted-container p,
[data-theme] .highlighted-container li,
[data-theme] .content-text p,
[data-theme] .content-text li {
  color: var(--body);
}

[data-theme] .cms-div .text-small,
[data-theme] .components-div .text-small {
  color: var(--muted);
}

/* Section titles and the lead-in of a point are the ink itself. */
[data-theme] .heading-4,
[data-theme] .content-text h4,
[data-theme] .content-text strong,
[data-theme] .highlighted-container strong {
  color: var(--ink);
}

/* "Available Worldwide" sat at 1.77:1 on a light page: the green was picked
   for a dark background and never re-picked for this one. */
[data-theme] .label-live-text,
[data-theme] .label-live {
  color: var(--live-ink);
}

[data-theme] .label-live {
  border-color: color-mix(in srgb, var(--live-ink) 45%, transparent);
}

:root {
  --live-ink: #1d7a3f;
}

[data-theme='dark'] {
  --live-ink: #68da6d;
}

/* The tab label and "Read More" were both at 4.14:1 — under AA for their size. */
[data-theme] .tab {
  color: var(--muted);
}

[data-theme] .button-small,
[data-theme] .button-text {
  color: var(--accent-ink);
}

/* --- Work card artwork: white card, lit panel ------------------------------
   The card is the page's own white; the artwork half carries a coloured glow
   behind the screenshot, one hue per project, the way the reference lights
   each piece of work differently. */
[data-theme] .case-study-div {
  background-color: var(--page);
}

.image-column,
.cover-image-container[data-ft] {
  /* Two coloured lights on a near-black base, plus one more in the corner.
     Reaching the lights past every edge instead flattened the panel into a
     single slab of blue — the depth is the point, so the geometry is back as
     it was and only the dark corner is fixed. */
  background:
    /* The corner light. Only an L of this panel is ever visible — 32px along
       the top and down the left, the rest is under the screenshot — and the
       two lights below both miss its top-left, which left the one corner you
       actually look at sitting on the bare base. */
    radial-gradient(62% 58% at 0% 0%,
      color-mix(in srgb, var(--glow-a, #7aa2ff) 60%, transparent) 0%, transparent 72%),
    radial-gradient(90% 85% at 72% 18%, var(--glow-a, #7aa2ff) 0%, transparent 62%),
    radial-gradient(80% 90% at 20% 92%, var(--glow-b, #b388ff) 0%, transparent 58%),
    linear-gradient(135deg, var(--glow-base, #10131c) 0%, var(--glow-base-2, #05070c) 100%);
}

[data-theme='dark'] .image-column,
[data-theme='dark'] .cover-image-container[data-ft] {
  /* Two coloured lights on a near-black base, plus one more in the corner.
     Reaching the lights past every edge instead flattened the panel into a
     single slab of blue — the depth is the point, so the geometry is back as
     it was and only the dark corner is fixed. */
  background:
    /* The corner light. Only an L of this panel is ever visible — 32px along
       the top and down the left, the rest is under the screenshot — and the
       two lights below both miss its top-left, which left the one corner you
       actually look at sitting on the bare base. */
    radial-gradient(62% 58% at 0% 0%,
      color-mix(in srgb, var(--glow-a, #7aa2ff) 60%, transparent) 0%, transparent 72%),
    radial-gradient(90% 85% at 72% 18%, var(--glow-a, #7aa2ff) 0%, transparent 62%),
    radial-gradient(80% 90% at 20% 92%, var(--glow-b, #b388ff) 0%, transparent 58%),
    linear-gradient(135deg, var(--glow-base, #10131c) 0%, var(--glow-base-2, #05070c) 100%);
}


/* The 404 copy is a bare <div> inheriting Webflow's .body colour (#fff), so on
   the light page it rendered white on white — a measured 1:1. */
[data-theme] .utility-page-content,
[data-theme] .utility-page-content h2,
[data-theme] .utility-page-content div {
  color: var(--ink);
}

[data-theme] .utility-page-wrap {
  background-color: var(--page);
}



/* The ported Gradient ASCII paints the whole hero, unlike the ripple which
   drew glyphs on a transparent field.

   The field is drawn taller than the hero and anchored above it, which lifts
   the bright horizon band off the headline: the copy then sits on the dark
   falloff and reads without needing a scrim over the top of it. */
.gradient-ascii-canvas {
  position: absolute;
  top: -26%;
  left: 0;
  right: 0;
  width: 100%;
  height: 126%;
  display: block;
  pointer-events: none;
}

@media screen and (max-width: 767px) {
  .gradient-ascii-canvas {
    top: -18%;
    height: 118%;
  }
}

/* --- Case study page: one centred column ----------------------------------
   The top block (title, intro, meta) stays as it was. Below it, everything
   runs in a single centred column and the images sit at the same width as the
   text, so the page reads as one line down the middle rather than text in a
   narrow column with artwork breaking out around it. */
.content-section {
  align-items: center;
}

.content-section > .w-container,
.content-section > .text-div-v {
  margin-left: auto;
  margin-right: auto;
}

/* The gap between two blocks belongs to the pair, not to either one of them.
   This works because .content-section is display:block — the four flex
   properties on it in both stylesheets are inert, which is also why the
   centring above is done with auto margins. */
.content-section > * + * {
  margin-top: var(--block-gap);
}

/* Webflow writes the old numbers as combo classes, which are (0,2,0) and
   would otherwise beat the rule above. These are the only three that appear
   on a case study. Specificity (0,3,0), and webflow.css is never edited. */
[data-theme] .content-section > .image-container-works.margin-top-56px,
[data-theme] .content-section > .highlighted-container.margin-top-32px,
[data-theme] .content-section > .info-boxes-container.margin-top-32px {
  margin-top: var(--block-gap);
}

[data-theme] .image-container-works {
  max-width: var(--media);
  width: 100%;
  margin-left: auto;
  margin-right: auto;
  padding: 0;
  background-color: transparent;
  border-radius: 16px;
  overflow: hidden;
}

[data-theme] .image-container-works img,
[data-theme] .image-container-works .cms-image {
  display: block;
  width: 100%;
  height: auto;
  border-radius: 16px;
  /* A pure-black hairline in light and pure white in dark; a tinted neutral
     picks up the surface under it and reads as dirt on the image edge. */
  outline: 1px solid rgba(0, 0, 0, 0.1);
  outline-offset: -1px;
}

[data-theme='dark'] .image-container-works img,
[data-theme='dark'] .image-container-works .cms-image {
  outline-color: rgba(255, 255, 255, 0.1);
}

/* The feature grids and component tiles follow the same column. */
[data-theme] .cms-div,
[data-theme] .components-div,
[data-theme] .highlighted-container,
[data-theme] .info-boxes-container {
  max-width: var(--column);
  margin-left: auto;
  margin-right: auto;
}

/* Webflow caps the reading column at 760 in half a dozen places while the
   cards and the footer run to 900. Every one of them is pulled onto the same
   column here, so the case study and the about page line up with the home
   page instead of sitting inside it. */
[data-theme] .text-div-v.max-width-760px,
[data-theme] .hero-text-div,
[data-theme] .container-56px-bm,
[data-theme] .heading-2.max-width-720px {
  max-width: var(--column);
}

/* Headings get room above them so each section reads as a new beat. */
[data-theme] .content-text h4 {
  margin-top: 8px;
  margin-bottom: 12px;
}

[data-theme] .content-text p {
  text-wrap: pretty;
}

/* Max: "do better with text in 2 lines." A headline that wraps leaves its last
   line short — the 404's broke 673px over 163px, one orphan word on its own
   line, and the home h1 ran 650/706/461. text-wrap: balance hands the line
   breaking back to the browser, which evens the widths out: 424/412 and
   650/600/569 respectively. It is one rule for every display size rather than
   a hand-tuned <br/> per headline, which would be a second copy of the copy.
   Chrome only balances up to four lines, so on a phone — where these run to
   five or six — nothing changes and nothing breaks. */
[data-theme] .heading-1,
[data-theme] .heading-2,
[data-theme] .heading-3 {
  text-wrap: balance;
}


/* Max: "make consistent space." The dock floats 10px clear of the bottom of
   the window; the header was sitting 20px clear of the top, so the two ends of
   the page did not answer each other. One inset, both ends. */
/* Max: "make consistent space", then "keep consistent spacing, but maybe add a
   bit more". One number for every floating thing: the header on the home page,
   the header on a case study — which is .works-header, a different element,
   which is why it was never getting this — and the dock at the other end. */
[data-theme] .navbar,
[data-theme] .works-header {
  padding-top: var(--float-inset);
}


/* --- Theme switch ---------------------------------------------------------
   Two tabs with one thumb sliding between them, in the slot the burger menu
   used to hold. The state is the position of the thumb, so there is nothing
   to read and nothing to guess at — unlike a single toggle, which always has
   to be interpreted as either "the theme you are in" or "the theme you will
   get". Navigation moved out: Home is the avatar, About is the hero link, CV
   is in the dock. */
.theme-switch {
  position: relative;
  display: inline-flex;
  align-items: center;
  gap: 2px;
  padding: 4px;
  border-radius: 999px;
  background: var(--glass-bg);
  box-shadow:
    inset 0 1px 0 var(--glass-hi),
    inset 0 -1px 0 var(--glass-lo),
    var(--glass-cast);
  backdrop-filter: var(--glass-blur);
  -webkit-backdrop-filter: var(--glass-blur);
}

.theme-switch-thumb {
  position: absolute;
  top: 4px;
  left: 4px;
  width: 36px;
  height: 36px;
  border-radius: 999px;
  background: var(--surface);
  box-shadow: inset 0 0 0 1px var(--line);
  transition: transform 260ms cubic-bezier(0.23, 1, 0.32, 1);
  pointer-events: none;
}

[data-theme='dark'] .theme-switch-thumb {
  transform: translateX(38px);
}

.theme-opt {
  position: relative;
  z-index: 1;
  display: grid;
  place-items: center;
  width: 36px;
  height: 36px;
  padding: 0;
  border: 0;
  border-radius: 999px;
  background: none;
  /* Max: "the sun is barely visible." It was being dimmed twice — --faint for
     the colour and another 0.5 on the icon's own opacity, which multiply to
     19% white. Over the hero's blue field that is a ghost. One mechanism now:
     the colour is the ink, and the unlit state is the opacity below. */
  color: var(--ink);
  cursor: pointer;
  -webkit-tap-highlight-color: transparent;
  transition: transform 160ms cubic-bezier(0.23, 1, 0.32, 1);
}

/* The pane reads as one control, but its side padding and the 2px gap between
   the two options were dead — a tap at the pane's own corner hit the header,
   and one between the buttons hit the pane. The pseudo takes the whole pane
   and gives each option half the gap, so nothing is left over and the two
   never overlap. Asymmetric on purpose: symmetric insets wide enough to reach
   the pane's outer edge would have the two boxes cross in the middle, which is
   worse than a dead corner. The thumb is a <span>, so :first/:last-of-type
   pick the two buttons. Nothing visible changes. */
.theme-opt::after {
  content: '';
  position: absolute;
  inset: -6px -1px;
}

.theme-opt:first-of-type::after {
  left: -6px;
}

.theme-opt:last-of-type::after {
  right: -6px;
}

@media screen and (max-width: 767px) {
  .theme-opt::after {
    inset: -5px -1px;
  }

  .theme-opt:first-of-type::after {
    left: -5px;
  }

  .theme-opt:last-of-type::after {
    right: -5px;
  }
}

.theme-opt svg {
  width: 18px;
  height: 18px;
}

@media (hover: hover) and (pointer: fine) {
  /* Brushing the unlit side brings it most of the way up, so the control
     answers before it is clicked. The lit one is already at full. */
  .theme-opt:hover svg {
    opacity: 0.85;
  }
}

.theme-opt:active {
  transform: scale(0.92);
}

.theme-opt:focus-visible {
  outline: 2px solid var(--accent-ink);
  outline-offset: 2px;
}

@media (prefers-reduced-motion: reduce) {
  .theme-switch-thumb,
  .theme-opt {
    transition: none;
  }

  .theme-opt:active {
    transform: none;
  }
}


/* --- Black and white only -------------------------------------------------
   No accent colour anywhere. Webflow's own --blue is redefined above, which
   neutralises a:hover, .button-text, .button-small, .link:hover, .tab:hover
   and .menu-link in one move. These are the rules that hard-code a hex and so
   slip past that. */
[data-theme] .button-text.left-align:hover,
[data-theme] .button-small:hover,
[data-theme] .text-3 {
  color: var(--ink);
}

[data-theme] a:hover {
  border-bottom-color: currentColor;
}

/* Links earn their emphasis from weight and an underline on hover, not hue. */
[data-theme] .button-small,
[data-theme] .button-text {
  color: var(--ink);
  text-decoration-color: color-mix(in srgb, var(--ink) 35%, transparent);
  transition: text-decoration-color 180ms cubic-bezier(0.23, 1, 0.32, 1), opacity 180ms cubic-bezier(0.23, 1, 0.32, 1);
}

@media (hover: hover) and (pointer: fine) {
  [data-theme] .button-small:hover,
  [data-theme] .button-text:hover {
    text-decoration: underline;
    text-decoration-color: currentColor;
    text-underline-offset: 3px;
  }
}

/* Dock hovers: the disc deepens and the mark goes to full ink. No hue. */
@media (hover: hover) and (pointer: fine) {
  .dock-btn:hover {
    background: color-mix(in srgb, var(--ink) 9%, var(--surface));
    color: var(--ink);
  }

  /* The circles lift 9% toward the ink; the pill lifts the same amount toward
     the page. Same move, opposite direction, because the pill is inverted.
     The colour is restated because Webflow's global `a:hover` repaints every
     anchor's text, which on an inverted pill means dark label on a dark fill. */
  .dock-cta:hover {
    background: color-mix(in srgb, var(--ink) 91%, var(--page));
    color: var(--page);
  }
}

/* Focus rings follow the ink too, with an offset ring so they stay visible on
   either surface. */
[data-theme] .dock-btn:focus-visible,
[data-theme] .dock-cta:focus-visible,
[data-theme] .theme-opt:focus-visible,
[data-theme] .tab:focus-visible,
[data-theme] .menu-link:focus-visible,
[data-theme] .social-media-div:focus-visible,
[data-theme] .case-study-div:focus-visible {
  outline: 2px solid var(--ink);
  outline-offset: 3px;
}

/* Nine control types had no ring of their own and fell through to Chrome's
   default — `outline: auto 1px rgb(153,200,255)`, which is blue, on a site
   whose palette is black and white plus a flag and one green pill. Worst on
   the header, where the avatar took the blue ring two inches from the theme
   switch's white one, and the two are a deliberately pinned pair. This is the
   same declaration .dock-btn/.dock-cta carry above; --accent-ink follows the
   pinned `data-on` polarity, so the back arrow and the switch still match each
   other over a pale cover and over a dark one. */
.ava:focus-visible,
.back-arrow:focus-visible,
.live-chip:focus-visible,
.button-text:focus-visible,
.demo-expand:focus-visible,
.frame-modal-close:focus-visible,
.dock-menu-item:focus-visible,
.shot-slider:focus-visible,
.slider-track:focus-visible,
.slider-step:focus-visible,
.slider-dot:focus-visible {
  outline: 2px solid var(--accent-ink);
  outline-offset: 3px;
}


/* --- Inline flag tile ------------------------------------------------------
   The Limitless creator flow seats logos inside a line of display text as
   rounded squares the height of the line box, tilted a few degrees so they
   read as objects laid on the page rather than as glyphs. One tile here, for
   the flag beside the country.

   Measured from the reference: a 40-unit square at rx 10 (a quarter of the
   side), sized to ~0.9 of the line box and nudged up 0.06em off the baseline.
   Sits square, not tilted — Max's call; the tilt read as a wobble at this size.
   0.9lh rather than an em, because the headline's line-height ratio is not the
   same at both widths and only lh tracks it. */
.flag-line {
  white-space: nowrap; /* the flag never gets orphaned at a line break */
}

/* Same device for an inline brand chip and the punctuation that follows it.
   The chip is an inline-block, so there is a break opportunity between it and
   the next character — measured, the sign-off's full stop was starting the
   next line on its own. The pair is one object; it wraps as one.

   `.keep-line` is the same declaration used on a run of words that should
   break as a unit. Max, on the sign-off's two-word last line: "maybe just
   better shaping / like send to next line sooner etc." No wrap mode moves it —
   `pretty`, `balance` and plain `wrap` all set 8/9/2 at 390 — so the break has
   to be forced earlier instead. Holding the closing clause together sets it
   8/6/5 and the last line stops being a fragment. */
.brand-line,
.keep-line {
  white-space: nowrap;
}

.flag-chip {
  display: inline-flex;
  vertical-align: middle;
  height: 1.05em; /* fallback where the lh unit is unsupported */
  height: 0.78lh;
  aspect-ratio: 3 / 2;
  margin-inline-end: 0.26em;
  transform: translateY(-0.06em);
}

.flag-chip svg {
  display: block;
  width: 100%;
  height: 100%;
  overflow: visible;
}


/* --- Selection ------------------------------------------------------------
   The blue the hero field is drawn in, so a selection reads as part of the
   site rather than as the browser's own. It tracks the theme for the same
   reason the field does. */
::selection {
  background: var(--select-bg);
  color: #ffffff;
  text-shadow: none;
}

/* --- Contact me glint -----------------------------------------------------
   Ported from the orbiting ring on Limitless' "Add to My Pack": a conic
   gradient whose start angle runs 0 to 360 degrees on an @property-registered
   custom property, masked down to a band at the edge so only the rim is
   painted. Two layers — a crisp rim and a blurred copy for the bloom — plus a
   third half a turn behind that fades in on hover, so the button gets a second
   glint exactly when it is being looked at. The 180 degree offset comes from
   the negative animation-delay, not from a start value, because the animation
   would win over a declared one anyway.

   The reference runs its ring outside the button, which works because that
   button is transparent on a white sheet. Ours is a filled pill, so the band
   rides the pill's own edge (the reference's own flush variant) and the colour
   is --page: white on the black pill in light, ink on the white pill in dark. */
@property --glint-a {
  syntax: '<angle>';
  initial-value: 0deg;
  inherits: false;
}

/* Webflow gives every <a> `border: 1px #000; border-bottom-style: dashed`, and
   anything that does not override it wears a dashed black line. On the pill that
   read as a stray second edge under the button. */
.dock-cta {
  position: relative;
  border-bottom-style: none;
}

.glint {
  position: absolute;
  inset: 0;
  padding: 2px;
  border-radius: inherit;
  pointer-events: none;
  background: conic-gradient(
    from var(--glint-a),
    transparent 0deg,
    color-mix(in srgb, var(--page) 32%, transparent) 40deg,
    var(--page) 74deg,
    var(--page) 78deg,
    transparent 80deg
  );
  -webkit-mask-image: linear-gradient(#000 0 0), linear-gradient(#000 0 0);
  -webkit-mask-clip: content-box, border-box;
  -webkit-mask-composite: xor;
  mask-image: linear-gradient(#000 0 0), linear-gradient(#000 0 0);
  mask-clip: content-box, border-box;
  mask-composite: exclude;
  animation: glint-orbit 2600ms linear infinite;
}

.glint-second {
  opacity: 0;
  animation-delay: -1300ms;
  transition: opacity 300ms cubic-bezier(0.23, 1, 0.32, 1);
}

@keyframes glint-orbit {
  to {
    --glint-a: 360deg;
  }
}

@media (hover: hover) and (pointer: fine) {
  .dock-cta:hover .glint-second {
    opacity: 1;
  }
}

@media (prefers-reduced-motion: reduce) {
  .glint {
    animation: none;
  }

  .glint-second {
    display: none;
  }
}


/* --- Work card hover ------------------------------------------------------
   The cards are off Webflow's engine now, so this is the whole hover and it
   is written once. IX2 used to grow them 2% — 18px on a 900px card, which
   read as the card lurching — and paint a blue-grey over the fill.

   The lift is a white wash rather than a grey: in dark it takes the card from
   #121212 toward #1f1f1f, and in light it resolves to the same white it
   already is, so the one rule does the right thing in both themes without a
   second branch and without reintroducing the grey. */
[data-theme] .case-study-div {
  background-color: var(--page);
  box-shadow: var(--card-cast);
  /* The LEAVING timing. The arriving one is longer and lives in :hover below.
     A card that grows slowly and settles back briskly reads as answering you;
     the same duration both ways reads as lag on the way out. */
  transition:
    border-color 260ms cubic-bezier(0.23, 1, 0.32, 1),
    box-shadow 260ms cubic-bezier(0.23, 1, 0.32, 1),
    scale 300ms cubic-bezier(0.23, 1, 0.32, 1);
}

@media (hover: hover) and (pointer: fine) {
  /* Max: "a bit for scale and much smoother animation."

     The growth is back. The note that used to sit here said it could not be —
     that a non-none `scale` up the tree makes a backdrop root and would flatten
     the frosting over the artwork. Measured this time rather than assumed: the
     pane frosts identically at scale 1 and at 1.01, screenshot for screenshot.
     The real constraint is only that 1% of a 900px card is 9px, so it stays
     small enough not to shove its neighbours.

     The whole hover arrives as one beat — the growth, the frosting and the
     pill — which is why these run past the 300ms the rest of the UI keeps to.
     An entrance may; a state flip may not. */
  [data-theme] .case-study-div:hover {
    scale: 1.01;
    border-color: color-mix(in srgb, var(--ink) 16%, transparent);
    /* Max: "and less shadow on hover also." The lift is carried by the 1%
       scale and the border; the cast only has to say the card came forward. */
    box-shadow: var(--card-cast-hover);
    transition:
      border-color 420ms cubic-bezier(0.23, 1, 0.32, 1),
      box-shadow 420ms cubic-bezier(0.23, 1, 0.32, 1),
      scale 520ms cubic-bezier(0.23, 1, 0.32, 1);
  }

  /* The press was being eaten by the line above it. `.case-study-div:active`
     is (0,2,0) and that :hover is (0,3,0) and later, so a mouse press on the
     biggest target on the site changed nothing at all — measured, the card
     held 909px right through mousedown. Specificity (0,4,0) here, so it wins
     wherever it sits. The press is the short part of the arc, not the long
     one: 120ms in, against the hover's 520. */
  [data-theme] .case-study-div:hover:active {
    scale: 0.996;
    transition:
      border-color 420ms cubic-bezier(0.23, 1, 0.32, 1),
      box-shadow 420ms cubic-bezier(0.23, 1, 0.32, 1),
      scale 120ms cubic-bezier(0.23, 1, 0.32, 1);
  }
}

/* The touch path never reaches the hover block, and inherited the hover-out
   duration from the themed base rule. Same 120ms as above. */
[data-theme] .case-study-div:active {
  transition: scale 120ms cubic-bezier(0.23, 1, 0.32, 1);
}


/* --- Footer ---------------------------------------------------------------
   The old footer was a mailto headline over four tinted social squares, which
   the dock already does better. What replaces it is a sign-off and the tools,
   in a heap you can throw around — the shape of Emil Kowalski's footer, built
   with our own physics (see tumble.js) because the component it comes from is
   a paid one. */
.site-footer {
  /* The dock used to need clearance here; it parks above the footer now, and
     the heap rests on the bottom of its own box, so nothing is owed below it. */
  padding-bottom: 0;
  background: var(--page);
}

.site-footer-inner {
  /* The site column, so the footer's left edge lines up with the hero, the
     cards and the case-study text. */
  width: min(var(--column), calc(100% - var(--column-gutter)));
  margin: 0 auto;
  padding: 56px 0 0;
}

/* A hairline, not a slab: the footer sits on the page's own white and this is
   the only thing marking where it starts. */
.site-footer-inner::before {
  content: '';
  display: block;
  height: 1px;
  margin-bottom: 56px;
  background: var(--line);
}

.footer-note {
  max-width: 52ch;
  margin: 0;
  color: var(--ink);
  font-size: 17px;
  line-height: 1.6;
  text-wrap: pretty;
}

/* --- Inline brand mark ----------------------------------------------------
   A logo seated in a line of running text, the same idea as .flag-chip: sized
   in `lh` so it tracks the line box at every width rather than being a fixed
   px that only looks right at one size, a radius a quarter of the side, and
   dropped below the baseline so it sits on the text's optical centre.

   Max: the footer used bold to mark Claude and the About page used bold to
   mark Limitless. A mark and a link carry that job now — bold in the middle of
   a sentence reads as emphasis, not as a name. */
/* Max: "can it be like a box / so on hover bg be like a liquid glass chip."
   The mark and its word become one pressable object in the middle of a
   sentence, and on hover the site's own glass pane fades in behind the pair.

   Two things keep the line of text perfectly still while that happens. The
   padding is always there and only the pane's colour changes, so nothing
   reflows on hover. And the negative margin cancels the vertical padding
   exactly — an inline-block is laid out by its MARGIN box, so -0.14em against
   +0.14em leaves the paragraph's leading as it was while the chip still has
   room to breathe around the text. */
.brand {
  display: inline-block;
  /* The mark and the word it belongs to are one object; a line break between
     them leaves a logo stranded at the end of a line. */
  white-space: nowrap;
  /* Max: "same space here for all sides, maybe a bit more on the right."
     The vertical padding is uneven ON PURPOSE. The mark is dropped below the
     baseline to its optical centre, which leaves more slack under it than
     over it inside the line box — measured at 1.02px above and 3.32px below —
     so symmetric padding produces an asymmetric chip. These two numbers cancel
     that out and land 5px clear on the top, bottom and left, 7px on the right. */
  padding: 0.234em 0.412em 0.099em 0.294em;
  /* Max: "I think paddings is too big here." The vertical padding was already
     cancelled exactly; the horizontal was only half cancelled, which left
     2.6px before the mark and 4.6px after the word — so "Max" and the comma
     after it sat apart, and the sign-off read with gaps in it. All four now
     cancel exactly: the chip's margin box IS the mark plus the word, so the
     sentence sets as if the chip were not there, and the pane keeps its 5px
     and 7px and simply bleeds over its neighbours on hover, which is what a
     sheet of glass laid on a line of text should do. */
  margin: -0.234em -0.412em -0.099em -0.294em;
  /* Concentric with the mark inside it: the mark's own 0.21lh plus the
     padding that sits around it. */
  /* Concentric: the mark's own radius plus the padding between the two, so the
     pane stops bending tighter than the tile 5px inside it. Both terms are
     already in this file — 0.21lh is .inline-mark's radius and 0.294em is this
     chip's left padding — so it stays concentric at 17px and at 16px without a
     second number to keep in step. */
  border-radius: calc(0.21lh + 0.294em);
  background-color: transparent;
  box-shadow: none;
  transition:
    background-color 240ms cubic-bezier(0.23, 1, 0.32, 1),
    box-shadow 240ms cubic-bezier(0.23, 1, 0.32, 1),
    scale 160ms cubic-bezier(0.23, 1, 0.32, 1);
}

/* The same recipe every other pane on the site uses — the dock, the theme
   switch, the chips. A component does not get a tint of its own. */
@media (hover: hover) and (pointer: fine) {
  .brand:hover,
  .brand:has(.inline-link:focus-visible) {
    background-color: var(--glass-bg);
    backdrop-filter: blur(14px) saturate(1.6);
    -webkit-backdrop-filter: blur(14px) saturate(1.6);
    box-shadow:
      inset 0 0 0 1px var(--line),
      inset 0 1px 0 var(--glass-hi),
      var(--glass-cast);
  }

}

.brand:active {
  scale: 0.96;
}

@media (prefers-reduced-motion: reduce) {
  .brand {
    transition: none;
  }

  .brand:active {
    scale: 1;
  }
}

.inline-mark {
  display: inline-block;
  width: 0.84lh;
  height: 0.84lh;
  margin-right: 0.22em;
  vertical-align: -0.18lh;
  border-radius: 0.21lh;
  object-fit: cover;
}

/* The underline sweeps in from the left instead of appearing: two gradient
   layers, a faint one always at full width and a solid one that grows. */
.inline-link {
  color: var(--ink);
  text-decoration: none;
  /* Webflow gives every <a> a 1px dashed bottom border and paints it on hover.
     Under a word in the middle of a sentence that is a second underline in a
     different style, sitting a pixel below the one this rule draws. */
  border-bottom: 0;
  background-image:
    linear-gradient(currentColor, currentColor),
    linear-gradient(
      color-mix(in srgb, currentColor 24%, transparent),
      color-mix(in srgb, currentColor 24%, transparent));
  background-repeat: no-repeat;
  background-position: 0 100%, 0 100%;
  background-size: 0 1px, 100% 1px;
  padding-bottom: 2px;
  transition: background-size 300ms cubic-bezier(0.23, 1, 0.32, 1);
}

@media (hover: hover) and (pointer: fine) {
  .inline-link:hover {
    background-size: 100% 1px, 100% 1px;
  }

  /* Inside a chip the pane is the hover, so both underlines go — the resting
     one included. A hairline under a word on a sheet of glass reads as a mark
     on the glass, and the chip has already said the word is a link. These sit
     after :hover above deliberately: same specificity, later wins. */
  .brand:hover .inline-link,
  .brand:has(.inline-link:focus-visible) .inline-link {
    background-size: 0 1px, 0 1px;
  }
}

.inline-link:focus-visible {
  outline: 2px solid var(--accent-ink);
  outline-offset: 3px;
  border-radius: 3px;
}

@media (prefers-reduced-motion: reduce) {
  .inline-link {
    transition: none;
  }
}

/* --faint is 38% white in dark, which is 3.58:1 on the footer slab — fine for a
   date stamp, not for a line of copy. The hierarchy comes from size instead. */
.footer-note-sub {
  margin-top: 8px;
  color: var(--muted);
  font-size: 15px;
}

.footer-mail {
  color: var(--ink);
  border-bottom-style: none;
  text-decoration: underline;
  text-decoration-color: color-mix(in srgb, var(--ink) 30%, transparent);
  text-underline-offset: 3px;
  transition: text-decoration-color 180ms cubic-bezier(0.23, 1, 0.32, 1);
}

@media (hover: hover) and (pointer: fine) {
  .footer-mail:hover {
    text-decoration-color: currentColor;
  }
}

/* --- Tumble wall ---------------------------------------------------------- */
.tumble {
  position: relative;
  /* Full width, so a thrown tile can travel the whole page. The tiles are
     still SEEDED into a band in the middle (see tumble.js) — dropped across
     the full width they land in one loose row, and a heap only reads as a heap
     when they have to stack on each other. */
  width: 100%;
  height: 180px;
  margin: 44px 0 0;
  overflow: hidden;
  /* pan-y so a vertical swipe still scrolls the page past the footer on a
     phone; a horizontal drag grabs a tile. */
  touch-action: pan-y;
  /* Nothing in here is text, and a drag across it was selecting the whole
     footer — which paints --select-bg over every icon. */
  user-select: none;
  -webkit-user-select: none;
}

.tumble-tile {
  position: absolute;
  top: 0;
  left: 0;
  display: grid;
  place-items: center;
  width: 72px;
  height: 72px;
  /* Radius is a quarter of the side, the same ratio the Limitless tiles use. */
  border-radius: 18px;
  /* Each tile IS the tool's own app icon — plate and all — so some come out
     white, some black, some coloured. That is what makes them recognisable;
     a mark normalised onto a plate of my choosing is not the logo anyone
     knows. The tile only supplies the corner radius and the shadow.

     No background: every icon is a fully opaque square (the ones that shipped
     with their own circular or rounded mask are flattened onto their own plate
     colour first), so there is nothing to see behind them — and a tile that
     has a colour of its own leaks it through the antialiased pixels at a
     rounded corner, which on a rotated tile reads as a white chip. */
  overflow: hidden;
  box-shadow:
    0 1px 2px rgba(3, 17, 28, 0.1),
    0 10px 22px -12px rgba(3, 17, 28, 0.45);
  cursor: grab;
  will-change: transform;
  /* The solver writes transform every frame; nothing else may transition it. */
  transition: box-shadow 180ms cubic-bezier(0.23, 1, 0.32, 1);
}

.tumble-tile:active {
  cursor: grabbing;
}

@media (hover: hover) and (pointer: fine) {
  .tumble-tile:hover {
    box-shadow:
      0 2px 4px rgba(3, 17, 28, 0.12),
      0 16px 30px -14px rgba(3, 17, 28, 0.55);
  }
}

.tumble-tile img {
  /* The tile is the draggable object; the image inside it must not start a
     native image drag of its own. */
  -webkit-user-drag: none;
  pointer-events: none;
  display: block;
  width: 100%;
  height: 100%;
  object-fit: cover;
  pointer-events: none;
  -webkit-user-drag: none;
}

@media screen and (max-width: 767px) {
  /* Max: "paddings is too big here — meant side paddings." The slab already
     sits --float-inset (16px) off both screen edges, so 20px of padding inside
     it double-counts the gutter: 36px a side on a 390 screen, leaving 318px of
     measure. 12px brings the total to 28 and the measure to 334 without the
     type touching the slab's edge. The sign-off sets in 3 lines either way —
     the forced break costs nothing now the brand chips no longer add width. */
  .site-footer-inner {
    padding: 56px 12px 0;
  }

  /* 17px put "built with Claude." over two lines in a 334px measure; 16px fits
     the whole first sentence on one. Measured by the two chips' line box: at
     17 they sit 27px apart, at 16 and below they share a line. */
  .footer-note {
    font-size: 16px;
  }


  .tumble {
    height: 220px;
    margin-top: 32px;
  }

  .tumble-tile {
    width: 58px;
    height: 58px;
    border-radius: 14px;
  }


}


/* --- Reveal ---------------------------------------------------------------
   One mechanism for the opening sequence and for everything further down the
   page. The hidden state is keyed on .reveal-ready, which an inline script in
   the head sets before the first paint — otherwise the content would paint,
   vanish and come back, because reveal.js is deferred. That same script takes
   the class off after two seconds if reveal.js never arrives, so a page whose
   script fails shows everything rather than nothing.

   Keyframes, not a transition. A transition on [data-reveal] loses the
   specificity fight with every component that declares its own `transition`
   shorthand — .button-text did exactly that and silently dropped the stagger.
   An animation sits on a different channel and collides with nothing.

   `backwards` and not `both`: it holds the opening frame through the delay,
   and then lets go, so an element that owns its own transform afterwards (the
   dock, the photo cards) gets it back. */
.reveal-ready [data-reveal]:not([data-revealed]) {
  opacity: 0;
}

[data-reveal][data-revealed] {
  animation: reveal-in 680ms cubic-bezier(0.23, 1, 0.32, 1) var(--reveal-delay, 0ms) backwards;
}

@keyframes reveal-in {
  from {
    opacity: 0;
    filter: blur(6px);
    transform: translateY(var(--reveal-shift, 18px));
  }
}

[data-reveal][data-reveal-from='top'] {
  --reveal-shift: -20px;
}

[data-reveal][data-reveal-from='bottom'] {
  --reveal-shift: 26px;
}

/* The dock is centred on the page and lifted clear of the footer, and the
   photo cards sit at their own offset and angle. Both need an opening frame
   that composes with the transform they keep. */
.dock[data-revealed] {
  animation-name: reveal-in-dock;
}

/* No `filter` in this one, and that is the whole point. An element filter is a
   backdrop root, so for as long as the entrance runs every `backdrop-filter`
   inside the element samples nothing — measured: the dock and both headers
   carried a filter from 400ms to about 1100ms after load, which is exactly the
   "1 sec when the bg of buttons is not blurred" Max saw. Anything holding
   glass opens on opacity and movement alone. */
@keyframes reveal-in-dock {
  from {
    opacity: 0;
    transform: translateX(-50%) translateY(26px);
  }
}

.navbar[data-revealed],
.works-header[data-revealed] {
  animation-name: reveal-in-glass;
}

@keyframes reveal-in-glass {
  from {
    opacity: 0;
    transform: translateY(var(--reveal-shift, 18px));
  }
}

.bounce-card[data-revealed] {
  animation-name: reveal-in-card;
}

@keyframes reveal-in-card {
  from {
    opacity: 0;
    filter: blur(6px);
    transform: translateX(calc(var(--x) * var(--spread))) translateY(28px) rotate(0deg) scale(0.9);
  }
}

@media (prefers-reduced-motion: reduce) {
  [data-reveal][data-revealed] {
    animation: none;
  }
}

/* --- Bounce cards ---------------------------------------------------------
   A fan of photographs, ported from React Bits' Bounce Cards. Measured from
   the original: a square card at a 25px radius inside a 5px white border, two
   stacked shadows (a tight contact one and a wide soft one), siblings offset
   along a row with a few degrees of rotation each and z-index climbing left to
   right. The entrance overshoots — that is the bounce — and on hover the card
   under the pointer straightens and its neighbours step aside. */
.bounce-cards {
  --card: 220px;
  --spread: 132px;
  --origin: 50%;
  position: relative;
  /* Every card is absolutely positioned, so the box has nothing to size itself
     from — and its parent is a shrink-to-fit flex column, which would leave it
     at zero width and anchor left: 50% to the wrong edge. */
  width: 100%;
  align-self: stretch;
  height: calc(var(--card) + 34px);
  /* Max asked for more room above the fan and less below it: the paragraph was
     sitting on top of the photographs while the gap under them read as a hole. */
  margin-top: 76px;
}

.bounce-card {
  position: absolute;
  top: 0;
  left: var(--origin);
  width: var(--card);
  height: var(--card);
  margin-left: calc(var(--card) / -2);
  overflow: hidden;
  border: 5px solid var(--page);
  border-radius: 25px;
  box-shadow:
    0 1px 2px rgba(3, 17, 28, 0.12),
    0 10px 28px -6px rgba(3, 17, 28, 0.32);
  transform: translateX(calc(var(--x) * var(--spread))) rotate(var(--r));
  z-index: calc(var(--i) + 1);
  /* Hover timing only. The ENTRANCE is the `reveal-in-card` keyframe above —
     it carries its own duration, its own overshoot and the stagger — so this
     transition never had a part in it. It did, however, inherit the stagger:
     `var(--reveal-delay)` stays on the element after the entrance has run, and
     the fourth card therefore sat still for 270ms (measured: 314ms to first
     movement) every single time it was hovered. That was the lag. */
  transition:
    transform 320ms cubic-bezier(0.34, 1.3, 0.64, 1),
    box-shadow 260ms cubic-bezier(0.23, 1, 0.32, 1);
}

.bounce-card img {
  display: block;
  width: 100%;
  height: 100%;
  object-fit: cover;
  /* Without these a click-drag peels a ghost image off the photograph on
     desktop and a long press opens the image sheet on a phone — both of which
     land in the middle of an interaction that is meant to be a flick. */
  pointer-events: none;
  user-select: none;
  -webkit-user-select: none;
  -webkit-user-drag: none;
}

.bounce-cards {
  /* A vertical swipe scrolls; the fan takes the horizontal. */
  touch-action: pan-y;
  -webkit-tap-highlight-color: transparent;
}

[data-fan='live'] .bounce-card {
  will-change: transform;
}

/* The photographs overlap, so :hover answers on whichever one is painted on
   top and the covered strip of each card is dead to the pointer. bounce-fan.js
   hit-tests the resting slots instead and gives every card an equal target.
   Everything below is the fallback for when that script does not run — which
   is why it is still here, and why it is switched off rather than deleted the
   moment the script claims the fan. */
[data-fan='live'] .bounce-card {
  transition: box-shadow 260ms cubic-bezier(0.23, 1, 0.32, 1);
}

[data-fan='live'] .bounce-card[data-active] {
  box-shadow:
    0 2px 4px rgba(3, 17, 28, 0.14),
    0 22px 48px -10px rgba(3, 17, 28, 0.44);
}

@media (hover: hover) and (pointer: fine) {
  .bounce-card:hover {
    transform: translateX(calc(var(--x) * var(--spread))) rotate(0deg) scale(1.06);
    box-shadow:
      0 2px 4px rgba(3, 17, 28, 0.14),
      0 20px 44px -10px rgba(3, 17, 28, 0.42);
    z-index: 10;
  }

  /* The script owns transform and z-index once it is live. */
  [data-fan='live'] .bounce-card:hover,
  [data-fan='live'] .bounce-card:hover ~ .bounce-card,
  [data-fan='live'] .bounce-card:has(~ .bounce-card:hover) {
    transform: none;
    z-index: auto;
  }

  /* Neighbours step aside, away from the hovered card on both sides.
     The pair of rules is symmetrical and counts nothing: a card moves right if
     a card BEFORE it is hovered, and left if a card AFTER it is hovered. The
     three hand-written :nth-child pairs that used to be here covered hovering
     the second and third card only — hovering the first or the last pushed
     nobody, which is the half of the interaction that looked broken. */
  .bounce-card:hover ~ .bounce-card {
    transform: translateX(calc(var(--x) * var(--spread) + 30px)) rotate(var(--r));
  }

  .bounce-card:has(~ .bounce-card:hover) {
    transform: translateX(calc(var(--x) * var(--spread) - 30px)) rotate(var(--r));
  }
}

/* Anchored to the left edge of the column instead of its centre, so the fan
   lines up with the headline under it. Cards then count 0,1,2,3 rather than
   straddling zero. */
.bounce-cards-start {
  --origin: calc(var(--card) / 2);
}

@media screen and (max-width: 767px) {
  .bounce-cards {
    --card: 116px;
    --spread: 70px;
    margin-top: 40px;
  }

  .bounce-card {
    border-width: 4px;
    border-radius: 18px;
  }
}

@media (prefers-reduced-motion: reduce) {
  .bounce-card {
    transition: box-shadow 300ms linear;
  }
}


/* The hero sat 160px clear of the first card, which read as a hole rather than
   a breath. 80 -> 48 took a fifth off it; Max then asked for 16px more off on
/* The slot the figure vacates on a link-out card. It is the only place the
   new-tab destination is written down — the pill that says "View" sits inside
   aria-hidden markup and is display:none on touch, so without this line the
   card would announce nothing and show nothing on a phone. */
.case-away {
  display: flex;
  flex-direction: column;
}

.case-away-host {
  color: var(--ink);
  font-family: Unbounded, sans-serif;
  /* A phrase, not a figure — the stat slot next door holds "~10x" and can be
     big. These are sentences, so they sit at reading size. */
  font-size: 16px;
  line-height: 1.3;
  /* Same as every other title here: sentence case in the source,
     capitalised by CSS. */
  text-transform: capitalize;
}


/* The gap under the hero, and the room the tab row needs above the cards.
   desktop and 32px on the phone, where the hole is a bigger share of the
   screen. This is the whole gap — the hero's own bottom padding is below the
   button and belongs to the hero. */
[data-theme] .works-section {
  padding-top: 32px;
}

@media screen and (max-width: 767px) {
  [data-theme] .works-section {
    padding-top: 16px;
  }
}


/* --- Seated case-study shots ----------------------------------------------
   Max's note, with Circle's marketing pages as the reference: a screenshot
   laid bare on the page is not okay. Every shot gets a section of its own —
   a tinted panel with real padding — and sits inside it on its own radius and
   shadow, so the product UI reads as a thing being shown rather than as part
   of the page.

   Webflow gave the panel a translucent white (#ffffffa3), which is why these
   glared on a dark page. The token follows the theme instead. */
[data-theme] .image-container-works,
[data-theme] .shot-slider {
  padding: var(--shot-pad);
  border-radius: 24px;
  background-color: var(--surface);
  /* Webflow clipped the panel; the shot's own shadow needs to escape it. */
  overflow: visible;
}

[data-theme] .image-container-works .cms-image {
  display: block;
  width: 100%;
  border-radius: 12px;
  box-shadow:
    0 1px 2px rgba(3, 17, 28, 0.08),
    0 20px 44px -20px rgba(3, 17, 28, 0.38);
}

@media screen and (max-width: 767px) {
  :root {
    --shot-pad: 16px;
    --block-gap: 40px;
  }

  [data-theme] .image-container-works,
  [data-theme] .shot-slider {
    border-radius: 18px;
  }

  /* The copy panels sat at Webflow's 24px here (16 below 480) while the shot
     panel next to them was 18 — same width, same fill, same ring, corners 6px
     apart, and nothing in the markup says they are different kinds of object.
     The third selector is (0,3,0) because webflow.css gives
     .highlighted-container.margin-top-32px its own radius, and that file is
     never edited. */
  [data-theme] .info-block,
  [data-theme] .highlighted-container,
  [data-theme] .highlighted-container.margin-top-32px {
    border-radius: 18px;
  }

  [data-theme] .image-container-works .cms-image {
    border-radius: 8px;
  }

  [data-theme] .image-container-works.shot-flush .cms-image {
    border-radius: 18px;
  }
}


/* --- Availability chip ----------------------------------------------------
   One capsule saying two things in turn. Direction B: real glass — it blurs
   and saturates the hero field behind it and carries a bright hairline along
   the top edge and a dark one along the bottom. That pair is what separates
   glass from a translucent rectangle, and it is the same material the dock is
   made of, so the two read as one family.

   The green is the one colour that survives the black-and-white rule here,
   because it is a status, not styling. */
.live-chip {
  position: relative;
  display: inline-flex;
  align-items: center;
  gap: 9px;
  height: 34px;
  padding: 0 14px;
  border-radius: 999px;
  border-bottom-style: none;
  color: var(--ink);
  font-family: Unbounded, sans-serif;
  font-size: 14px;
  font-weight: 400;
  letter-spacing: -0.01em;
  text-decoration: none;
  white-space: nowrap;
  background: var(--glass-bg);
  backdrop-filter: var(--glass-blur);
  -webkit-backdrop-filter: var(--glass-blur);
  box-shadow:
    inset 0 1px 0 var(--glass-hi),
    inset 0 -1px 0 var(--glass-lo),
    var(--glass-cast);
  transition: background-color 180ms cubic-bezier(0.23, 1, 0.32, 1);
}

@media (hover: hover) and (pointer: fine) {
  .live-chip:hover {
    background: var(--glass-bg-strong);
  }
}

.live-chip:active {
  transform: scale(0.97);
}

/* Two states stacked in one cell. The slot takes the width of whichever one is
   showing, measured once by live-chip.js and written to --slot-w, so the pill
   shrinks onto "Based in Poland" and grows back out — it follows the content
   instead of being pinned to the longest label.

   Width is a layout property and normally not worth animating, but there is no
   transform that resizes a glass pill without smearing its backdrop, and at
   this size it is a handful of pixels a frame. */
.live-slot {
  position: relative;
  display: grid;
  width: var(--slot-w, auto);
  height: 20px;
  overflow: hidden;
  transition: width 460ms cubic-bezier(0.23, 1, 0.32, 1);
}

.live-state {
  grid-area: 1 / 1;
  width: max-content;
  display: flex;
  align-items: center;
  gap: 7px;
  line-height: 20px;
  transition:
    transform 460ms cubic-bezier(0.23, 1, 0.32, 1),
    opacity 300ms cubic-bezier(0.23, 1, 0.32, 1),
    filter 300ms cubic-bezier(0.23, 1, 0.32, 1);
}

.live-state svg {
  flex: none;
  width: 16px;
  height: 16px;
  display: block;
}

.live-state .live-flag {
  width: 21px;
  height: 14px;
}

.live-state[data-in] {
  transform: translateY(0);
  opacity: 1;
  filter: blur(0);
}

.live-state[data-out] {
  transform: translateY(-110%);
  opacity: 0;
  filter: blur(2px);
}

.live-state[data-next] {
  transform: translateY(110%);
  opacity: 0;
  filter: blur(2px);
}

/* The tooltip the old chip had, rebuilt. It hangs below the capsule rather
   than above it, because the chip sits near the top of the hero and there is
   nothing above it to hang into. Opacity and visibility together, never
   display — display cannot transition. */
.live-tip {
  position: absolute;
  top: calc(100% + 9px);
  left: 50%;
  padding: 5px 10px;
  border-radius: 8px;
  background: var(--ink);
  color: var(--page);
  /* Inter, for the same reason as .dock-tip, and stated for the same reason:
     .live-chip around it is Unbounded. */
  font-family: Inter, sans-serif;
  font-size: 13px;
  font-weight: 500;
  letter-spacing: -0.005em;
  white-space: nowrap;
  pointer-events: none;
  opacity: 0;
  visibility: hidden;
  transform: translate(-50%, -4px);
  transition:
    opacity 160ms cubic-bezier(0.23, 1, 0.32, 1),
    transform 160ms cubic-bezier(0.23, 1, 0.32, 1),
    visibility 0s linear 160ms;
}

@media (hover: hover) and (pointer: fine) {
  .live-chip:hover .live-tip,
  .live-chip:focus-visible .live-tip {
    opacity: 1;
    visibility: visible;
    transform: translate(-50%, 0);
    transition-delay: 0s;
  }
}

@media (prefers-reduced-motion: reduce) {
  .live-slot {
    transition: none;
  }

  .live-tip {
    transition: none;
  }

  .live-state {
    transition: none;
  }

  .live-dot::after {
    animation: none;
  }
}


/* --- Two theme-blind rules the Webflow inventory turned up ----------------
   Both were hard-coded in webflow.css and never followed the theme. */

/* A white close disc on a dark toast: .message is display:flex at phone width
   on every case-study page, so this was visible, not theoretical. */
[data-theme] .close-message {
  background-color: var(--surface);
  border-color: var(--line);
  color: var(--ink);
}

/* The skills tags lit orange on hover — the last coloured hover on the site. */
[data-theme] .my-skills:hover {
  color: var(--ink);
  background-color: color-mix(in srgb, var(--ink) 8%, transparent);
  border-color: color-mix(in srgb, var(--ink) 18%, transparent);
}


/* --- Glass everywhere -----------------------------------------------------
   Max: glass is the default, and everything pressable should be made of it.
   The pill stays the exception in weight, not in material — it is the same
   pane with the ink tint behind it, so it still wins the eye as the primary
   action while reading as part of the same family.

   The hover of every pane is the same move: the tint goes up, nothing else. */
/* Max: "let's try to match the buttons color / style with the elements on top."
   They did not match, and the reason was this rule: the dock circles were
   filled with the PAGE colour at 86%, which is a near-opaque disc, while the
   avatar, the availability chip and the theme switch are all --glass-bg — a
   white tint at 14% that takes its colour from whatever is behind it. One
   recipe means one recipe, so the exception is gone. */
[data-theme] .dock-btn {
  background-color: var(--glass-bg);
  /* The same tint as the header, and for the same reason — Max asked for them
     to match. What the header does not need is the floor below.

     A header control only ever floats over one known cover photograph, and
     header-contrast.js reads it. The dock floats over whatever is on the page
     at that scroll position: a near-black terminal field on one card, a white
     Kyrrex dashboard on the next. At 14% tint a white mark on a white
     screenshot disappears — measured, and it is why this rule used to fill the
     circle with the page colour at 86% instead. So the material stays glass
     and the backdrop is pushed toward the page's own end of the range first.
     Same blur, same tint, same family; only the contrast is guaranteed. */
  backdrop-filter: var(--glass-blur) brightness(var(--dock-floor));
  -webkit-backdrop-filter: var(--glass-blur) brightness(var(--dock-floor));
}

[data-theme] .dock-btn:hover,
[data-theme] .theme-switch-thumb,
[data-theme] .my-skills,
[data-theme] .highlighted-container,
[data-theme] .back-arrow {
  backdrop-filter: var(--glass-blur);
  -webkit-backdrop-filter: var(--glass-blur);
}

[data-theme] .theme-switch-thumb {
  background: var(--glass-bg-strong);
  box-shadow:
    inset 0 1px 0 var(--glass-hi),
    inset 0 -1px 0 var(--glass-lo),
    0 1px 2px rgba(3, 17, 28, 0.12);
}

[data-theme] .my-skills {
  background-color: var(--glass-bg);
  border-color: transparent;
  box-shadow:
    inset 0 1px 0 var(--glass-hi),
    inset 0 -1px 0 var(--glass-lo),
    var(--glass-cast);
}

/* The two round marks in the header — the avatar on the home page, the back
   arrow on a case study — are the same material as the theme switch sitting
   opposite them. Max asked for exactly that: one pane recipe, so the two ends
   of the header match. The arrow itself is drawn in ink rather than baked
   into an SVG, which is what left it a black glyph on a dark pane. */
[data-theme] .ava,
[data-theme] .back-arrow {
  background: var(--glass-bg);
  border-color: transparent;
  color: var(--ink);
  box-shadow:
    inset 0 1px 0 var(--glass-hi),
    inset 0 -1px 0 var(--glass-lo),
    var(--glass-cast);
  backdrop-filter: var(--glass-blur);
  -webkit-backdrop-filter: var(--glass-blur);
}

.back-arrow-glyph {
  display: block;
  width: 56px;
  height: 56px;
}

/* Both round marks — the avatar on the home page and the back arrow on a case
   study — shrank to 0.9 on hover under Webflow's engine, over 400ms on its
   default easing. That interaction went with the engine; this is the same
   move, on our easing and inside the 300ms UI budget, with a little further
   to go on the press so the click answers. */
.ava,
.back-arrow {
  transition:
    scale 220ms cubic-bezier(0.23, 1, 0.32, 1),
    background-color 180ms cubic-bezier(0.23, 1, 0.32, 1),
    box-shadow 180ms cubic-bezier(0.23, 1, 0.32, 1);
}

@media (hover: hover) and (pointer: fine) {
  .ava:hover,
  .back-arrow:hover {
    scale: 0.92;
  }
}

.ava:active,
.back-arrow:active {
  scale: 0.88;
}

@media (prefers-reduced-motion: reduce) {
  .ava,
  .back-arrow {
    transition: background-color 180ms linear;
  }

  .ava:hover,
  .ava:active,
  .back-arrow:hover,
  .back-arrow:active {
    scale: 1;
  }
}

[data-theme] .highlighted-container {
  background-color: var(--glass-bg);
  box-shadow:
    inset 0 1px 0 var(--glass-hi),
    inset 0 -1px 0 var(--glass-lo),
    var(--glass-cast);
}

@media (hover: hover) and (pointer: fine) {
  [data-theme] .dock-btn:hover,
  [data-theme] .my-skills:hover {
    background-color: var(--glass-bg-strong);
  }

  [data-theme] .ava:hover,
  [data-theme] .back-arrow:hover {
    background-color: var(--glass-bg-strong);
  }
}

/* --- Light surfaces: one device ------------------------------------------
   Max: "surfaces like this look not that good on white theme, pls come up fir
   few variations" — then picked the grey-fill-plus-hairline one.
   The problem was never the grey. Six panels were using three different
   devices: a bare #f5f5f5 fill with no edge at all (.info-block, the shot
   panels, the slider), full glass (.highlighted-container, .my-skills), and a
   page-coloured fill held up by a hairline and a cast (the work cards). And on
   a flat white page the glass recipe collapses into none of them: blur(14px)
   over uniform white returns white, saturate(1.6) leaves an achromatic colour
   alone, and --glass-hi resolves to exactly #ffffff, so the bright top
   hairline does not exist. What looked like glass was a 62% grey tint with one
   bottom hairline.
   So in light they all become the thing dark has always done: the surface
   tint, and a real 1px edge drawn as an inset ring rather than a border, which
   costs no layout. Glass stays the material for things that FLOAT over content
   — the dock, the header, the chips — where there is something behind them
   worth blurring. A panel sitting in the page flow has nothing behind it. */
[data-theme='light'] .info-block,
[data-theme='light'] .highlighted-container,
[data-theme='light'] .image-container-works,
[data-theme='light'] .shot-slider,
[data-theme='light'] .my-skills {
  background-color: var(--surface);
  box-shadow: inset 0 0 0 1px var(--line);
  backdrop-filter: none;
  -webkit-backdrop-filter: none;
}

@media (hover: hover) and (pointer: fine) {
  [data-theme='light'] .my-skills:hover {
    background-color: var(--surface-2);
  }
}


/* The CTA: the same pane, tinted with ink rather than with the page. It keeps
   its weight without stepping outside the material. */
.dock-cta {
  background-color: color-mix(in srgb, var(--ink) 92%, transparent);
  backdrop-filter: var(--glass-blur);
  -webkit-backdrop-filter: var(--glass-blur);
  box-shadow:
    inset 0 1px 0 color-mix(in srgb, var(--page) 28%, transparent),
    inset 0 -1px 0 rgba(0, 0, 0, 0.35),
    0 2px 6px -2px rgba(3, 17, 28, 0.3),
    0 16px 32px -14px rgba(3, 17, 28, 0.45);
}

@media (hover: hover) and (pointer: fine) {
  .dock-cta:hover {
    background-color: color-mix(in srgb, var(--ink) 84%, transparent);
    color: var(--page);
  }
}


/* Max: the avatar wore a white frame in dark mode. Webflow hard-codes
   `border: 4px solid var(--white)` on it; the photo cards next to it use the
   page colour, so the avatar does too. */
[data-theme] .ava-img {
  border-color: var(--page);
}


/* --- Theme swap -----------------------------------------------------------
   The tokens change in one frame, so without this every colour on the page
   snaps. dock.js puts this attribute on <html> for the length of the swap and
   takes it off again, so the cost is 340ms once per toggle rather than a
   transition every element carries forever.

   It sits at the end of the file on purpose: the properties are named, not
   `all`, and matching a component rule's specificity means source order has to
   break the tie in favour of the swap. */
[data-theme-changing],
[data-theme-changing] *,
[data-theme-changing] *::before,
[data-theme-changing] *::after {
  transition-property: background-color, border-color, color, fill, stroke, box-shadow, outline-color;
  transition-duration: 320ms;
  transition-timing-function: cubic-bezier(0.23, 1, 0.32, 1);
  transition-delay: 0s;
}

/* The one exception: the dock is mid-entrance on first paint and the heap in
   the footer is a physics simulation. Neither wants a colour transition
   retimed underneath it. */
[data-theme-changing] .tumble-tile,
[data-theme-changing] .glint {
  transition-property: none;
}

/* Two different pictures swap here — a photograph for a memoji — so it goes
   soft rather than cutting. The card's own veil already makes this argument:
   "a pre-blurred pane appearing is two pictures dissolving, a blur that grows
   is one picture going soft." Measured before: both images had
   transition-duration 0s and the whole swap happened in a single frame.
   The comment further up this file arguing for an instant swap was written
   about the old wink between two memoji frames, where there genuinely is no
   in-between state; it predates the photograph. */
[data-theme] .ava .sticker-1 {
  transition:
    opacity 180ms cubic-bezier(0.23, 1, 0.32, 1),
    scale 180ms cubic-bezier(0.23, 1, 0.32, 1),
    filter 180ms cubic-bezier(0.23, 1, 0.32, 1);
}

[data-theme] .ava .sticker-1._2 {
  scale: 0.94;
  filter: blur(4px);
}

@media (hover: hover) and (pointer: fine) {
  .ava:hover .sticker-1._2 {
    scale: 1;
    filter: blur(0px);
  }

  .ava:hover .sticker-1.ava-photo {
    scale: 0.94;
    filter: blur(4px);
  }
}

@media (prefers-reduced-motion: reduce) {
  [data-theme] .ava .sticker-1 {
    transition: none;
    scale: 1;
    filter: none;
  }
}


/* The segmented control: the thumb slides, the chosen icon comes up to full
   ink while the other settles back. Scale and opacity only, both composited,
   on the icon rather than the button so the 40px hit area never moves. */
.theme-opt svg {
  /* Was `cubic-bezier(0.2, 0, 0, 1)` at 300ms — initial slope 0/0.2 = 0, so it
     opened from a standstill and the icon lagged the page, which has already
     flipped. Measured against the house curve over the same move: 15.6% of it
     delivered in the first 30ms, against 39.8%. The one non-house easing left
     on a live control. */
  transition:
    scale 240ms cubic-bezier(0.23, 1, 0.32, 1),
    opacity 240ms cubic-bezier(0.23, 1, 0.32, 1);
}

.theme-opt[aria-pressed='false'] svg {
  scale: 0.86;
  /* 45% of the ink, and nothing else dimming it. Legible on the hero's blue,
     on a white dashboard cover and on the page itself, while still reading as
     the side you are not on. */
  opacity: 0.45;
}

.theme-opt[aria-pressed='true'] svg {
  scale: 1;
  opacity: 1;
}

/* 0.96 — anything below 0.95 reads as exaggerated at this size. */
.theme-opt:active {
  transform: scale(0.96);
}

@media (prefers-reduced-motion: reduce) {
  .theme-opt svg {
    transition: none;
  }
}


/* --- About: the gap under the photo fan ------------------------------------
   Webflow left 120px below the section and the fan adds its own 34, which put
   roughly a screenful of nothing between the last photograph and the dock. */
[data-theme] .section-about-page-2 {
  padding-bottom: 72px;
}

@media screen and (max-width: 767px) {
  [data-theme] .section-about-page-2 {
    padding-bottom: 48px;
  }
}


/* --- Header controls: one height ------------------------------------------
   Max: the avatar / back arrow (64px) and the theme switch (44px) read as two
   different components sitting in the same row. Both are now 52px — the middle
   of the two, so neither had to become the odd one out. */
[data-theme] .ava,
[data-theme] .back-arrow {
  padding: 3px;
}

[data-theme] .sticker-1,
.back-arrow-glyph {
  width: 46px;
  height: 46px;
}

/* Max: "a bit smaller arrow icon." Only the mark shrinks. The pane is one of a
   pair with the theme switch and the two were matched to 52px on purpose, so
   it is pinned here rather than left to size itself off its contents — which
   is what it was doing, and why taking 8px off the glyph took 8px off the
   circle with it. */
[data-theme] .back-arrow {
  width: 52px;
  height: 52px;
  display: grid;
  place-items: center;
  box-sizing: border-box;
}

.back-arrow-glyph {
  width: 38px;
  height: 38px;
}

.theme-switch {
  padding: 6px;
}

.theme-switch-thumb {
  top: 6px;
  left: 6px;
  width: 40px;
  height: 40px;
}

[data-theme='dark'] .theme-switch-thumb {
  transform: translateX(42px);
}

[data-theme] .theme-opt {
  width: 40px;
  height: 40px;
}


/* --- The avatar winks -----------------------------------------------------
   Max: "this guy was blinking on hover / was nice". Two memoji frames ship in
   the markup and Webflow's engine swapped them; that went when the header came
   off it. They share one grid cell and swap instantly now — a wink has no
   in-between state, so a fade would read as a dissolve rather than an eye. */
[data-theme] .ava {
  display: grid;
  place-items: center;
}

[data-theme] .ava .sticker-1 {
  grid-area: 1 / 1;
}

[data-theme] .ava .sticker-1._2 {
  display: block;
  opacity: 0;
}

/* Max: photograph at rest, memoji on hover. The photograph is a square frame
   and the pane is a circle, so it has to be clipped to one; the memoji is a
   cut-out and sits in the glass with nothing behind it. Keeping both means the
   memoji stops being the identity and becomes the joke — which is the right
   way round for a face you can also just look at. */
/* "Hire Me!" — the avatar's answer to a click.

   Max: "it should just go from 100 to 0 and from 6px below ava to 12px below,
   and faster disappear, like 1sec only."

   So it is one movement, not a state that is held and then dismissed: it
   arrives at full opacity six pixels under the avatar and spends its whole
   second drifting six more while fading out. There is no hold and no bob —
   the drift IS the duration, which is why it reads as something let go of
   rather than something shown and then taken away.

   An animation rather than a transition, because this runs start to finish
   each time rather than travelling between two states. */
.ava-tip {
  position: absolute;
  top: calc(100% + 6px);
  left: 50%;
  z-index: 2;
  padding: 5px 11px;
  border-radius: 9px;
  background: var(--ink);
  color: var(--page);
  font-family: Inter, sans-serif;
  font-size: 13px;
  font-weight: 500;
  line-height: 18px;
  letter-spacing: -0.005em;
  white-space: nowrap;
  pointer-events: none;
  opacity: 0;
  visibility: hidden;
  /* --slot is the stacking offset, written by ava-tip.js: when a new label is
     pressed out, every label already in the air moves down one. It rides on
     `translate` while the drift rides on `transform` — two separate CSS
     properties that compose, so being pushed down by a newcomer and drifting
     away under its own steam never fight over the same declaration. */
  translate: -50% var(--slot, 0px);
  transition: translate 300ms cubic-bezier(0.23, 1, 0.32, 1);
}

/* Two animations, not one, because the two properties want opposite curves.

   On a single ease-out the whole fade lands in the first third — measured at
   0.05 opacity by 700ms of 1400 — so the extra time was an invisible tail and
   lengthening it changed nothing you could see. The drift keeps the ease-out
   so it moves the instant you press. The fade gets an ease-IN so the words
   stay readable and then go, which is the right shape for something being let
   go of. (The never-ease-in rule is about entrances, where it delays the first
   movement; here the first movement is the drift, and it is immediate.) */
.ava-tip[data-shown] {
  visibility: visible;
  animation:
    ava-drift 1400ms cubic-bezier(0.23, 1, 0.32, 1) forwards,
    ava-fade 1400ms cubic-bezier(0.55, 0, 0.85, 0.2) forwards;
}

@keyframes ava-drift {
  from {
    transform: translateY(0);
  }

  to {
    transform: translateY(6px);
  }
}

@keyframes ava-fade {
  from {
    opacity: 1;
  }

  to {
    opacity: 0;
  }
}

@media (prefers-reduced-motion: reduce) {
  .ava-tip[data-shown] {
    animation: none;
    opacity: 1;
  }
}

/* The pane has to hang outside the circle, so the circle cannot clip. */
[data-theme] .ava {
  position: relative;
  overflow: visible;
}

@media (prefers-reduced-motion: reduce) {
  .ava-tip,
  .ava-tip[data-shown] {
    transition: none;
    translate: -50% 0;
    scale: 1;
  }
}

[data-theme] .ava .ava-photo {
  /* Smaller than the memoji inside the same pane, and on purpose. The memoji
     is a cut-out with transparent air round it, so it already reads as sitting
     IN the glass; a photograph is a solid disc and fills the pane edge to edge
     unless it is told not to. These 3px give it the same breathing room the
     memoji has for free. */
  width: 40px;
  height: 40px;
  border-radius: 999px;
  object-fit: cover;
}

@media (hover: hover) and (pointer: fine) {
  .ava:hover .sticker-1 {
    opacity: 0;
  }

  .ava:hover .sticker-1._2 {
    opacity: 1;
  }
}


/* Max: "only specific kyrrex screen with no side paddings, on all others
   keep." One shot on the site is a composed scene that already carries its own
   margin inside the frame, so the panel's 40px was a second one around it.
   Every other shot still gets the full seat. Opt-in, per image. */
[data-theme] .image-container-works.shot-flush {
  padding: var(--shot-pad) 0;
}

[data-theme] .image-container-works.shot-flush .cms-image {
  /* Flush with the panel's own edges, so it has to take the panel's corner or
     the two radii fight. */
  border-radius: 24px;
}

/* Max: "on mobile = align all images." The flush exception is a DESKTOP
   judgement — that one composed scene carries its own margin inside the
   frame, so a 40px seat around it was a second one. The seat it would double
   on a phone is 16px, and the price of keeping the exception there is that
   Kyrrex is the one page with two different image left edges. So on a phone
   it stands on the seat like everything else, and takes the ordinary image
   corner rather than the panel's.
   This has to sit AFTER the rule above, not up in the earlier mobile block:
   both are (0,3,0) and a media query adds no specificity, so the later one
   wins at every width. Written earlier, it lost silently. */
@media screen and (max-width: 767px) {
  [data-theme] .image-container-works.shot-flush {
    padding: var(--shot-pad);
  }

  [data-theme] .image-container-works.shot-flush .cms-image {
    border-radius: 8px;
  }
}


/* The phone's panel shape and inset moved up into the one breakpoint block
   above — this was the same declaration written twice in two media queries,
   and the second one silently won. */


/* --- Header controls read what is behind them -----------------------------
   Max: "can it like understand the bg and change in the way it looks good on
   all bgs?" — header-contrast.js measures the cover photograph under each
   control and writes data-on. These are the two glass polarities, pinned to a
   literal value rather than a token, because the whole point is that they no
   longer follow the page theme. Each control decides for itself: the left and
   right of one cover are often different pictures. */
[data-theme] .back-arrow[data-on='light'],
[data-theme] .ava[data-on='light'],
.theme-switch[data-on='light'] {
  --ink: #03111c;
  --faint: rgba(3, 17, 28, 0.62);
  --surface: #f5f5f5;
  --line: rgba(3, 17, 28, 0.1);
  /* The same grey body the light page's glass has. These are pinned literals
     rather than tokens on purpose — the point of this block is that a control
     over a near-white cover stops following the page theme — but the material
     still has to be the one material, or the back arrow on a pale cover reads
     white while the dock two inches below it reads grey. */
  --glass-bg: rgba(232, 232, 232, 0.62);
  --glass-bg-strong: rgba(232, 232, 232, 0.78);
  --glass-hi: rgba(255, 255, 255, 0.95);
  --glass-lo: rgba(3, 17, 28, 0.08);
  --glass-cast: 0 1px 2px rgba(3, 17, 28, 0.1), 0 10px 24px -12px rgba(3, 17, 28, 0.4);
  color: var(--ink);
}

[data-theme] .back-arrow[data-on='dark'],
[data-theme] .ava[data-on='dark'],
.theme-switch[data-on='dark'] {
  --ink: #ffffff;
  --faint: rgba(255, 255, 255, 0.38);
  --surface: #1f1f1f;
  --line: rgba(255, 255, 255, 0.08);
  --glass-bg: rgba(255, 255, 255, 0.14);
  --glass-bg-strong: rgba(255, 255, 255, 0.2);
  --glass-hi: rgba(255, 255, 255, 0.3);
  --glass-lo: rgba(0, 0, 0, 0.3);
  --glass-cast: 0 10px 24px -12px rgba(0, 0, 0, 0.7);
  color: var(--ink);
}

/* The pane re-tints rather than cutting, so crossing the edge of a dark area
   in a photograph reads as the light changing on it. */
[data-theme] .back-arrow,
[data-theme] .ava,
.theme-switch,
.theme-switch-thumb,
.theme-opt {
  transition:
    background-color 260ms cubic-bezier(0.23, 1, 0.32, 1),
    box-shadow 260ms cubic-bezier(0.23, 1, 0.32, 1),
    color 260ms cubic-bezier(0.23, 1, 0.32, 1),
    transform 260ms cubic-bezier(0.23, 1, 0.32, 1),
    scale 220ms cubic-bezier(0.23, 1, 0.32, 1);
}

@media (prefers-reduced-motion: reduce) {
  [data-theme] .back-arrow,
  [data-theme] .ava,
  .theme-switch,
  .theme-switch-thumb,
  .theme-opt {
    transition: none;
  }
}


/* --- Read More, on the artwork --------------------------------------------
   Max: on hover the action moves to the right-hand side of the card, on top of
   the screenshot, and the picture behind it goes soft.

   Max: "no need to blur the img only — do that for the whole right section and
   make it like a bg blur, not a blur of element." Blurring the <img> left the
   terminal field behind it razor sharp beside it, which is what read as wrong.
   What frosts now is .case-veil, one pane across the whole artwork half with a
   real backdrop-filter, so the field, the screenshot and the panel under them
   all go soft together and the panel's edges stay crisp — an element filter
   smears past its own box, a backdrop one cannot.

   It dims as well as blurs, because the four screenshots are not one
   brightness: Kyrrex is a white dashboard and Limitless is near-black, and a
   pill light enough to read on one disappears on the other. */
/* The pill rides the cursor, and two elements carry that between them.
   .case-more-track owns the POSITION — one transform, rewritten every frame by
   card-pointer.js — and the pill inside owns the ENTRANCE. Two elements
   because they are two jobs: one element owning both would have to restate the
   entrance scale on every frame of the follow, and the two would fight.

   The track is a zero-size point sitting at the middle of the artwork, so with
   no script at all the pill is exactly where it has always been: dead centre.
   The script moves the point, never the pill. */
.case-more-track {
  position: absolute;
  z-index: 3;
  top: 50%;
  left: 50%;
  width: 0;
  height: 0;
  pointer-events: none;
}

.case-more {
  position: absolute;
  top: 0;
  left: 0;
  translate: -50% -50%;
  display: inline-flex;
  align-items: center;
  height: 48px;
  padding: 0 26px;
  border-radius: 999px;
  background: rgba(255, 255, 255, 0.18);
  box-shadow:
    inset 0 1px 0 rgba(255, 255, 255, 0.45),
    inset 0 -1px 0 rgba(0, 0, 0, 0.18),
    0 10px 28px -14px rgba(0, 0, 0, 0.65);
  color: #ffffff;
  font-family: Unbounded, sans-serif;
  font-size: 15px;
  letter-spacing: -0.01em;
  white-space: nowrap;
  /* The whole card is the link; the pill is a label on it, not a second
     target, and it must not eat the pointer. */
  pointer-events: none;
  opacity: 0;
  scale: 0.92;
  /* It resolves out of the blur rather than popping in at full sharpness —
     the same trick that hides the seam in an icon swap, used here to hide the
     frame where the pill is suddenly just there. `translate` and `scale` are
     their own properties now, so neither touches the track's transform. */
  filter: blur(5px);
  transition:
    opacity 200ms cubic-bezier(0.23, 1, 0.32, 1),
    scale 200ms cubic-bezier(0.23, 1, 0.32, 1),
    filter 200ms cubic-bezier(0.23, 1, 0.32, 1);
}

@media (hover: hover) and (pointer: fine) {
  .case-study-div:hover .case-more {
    opacity: 1;
    scale: 1;
    filter: blur(0px);
    transition:
      opacity 320ms cubic-bezier(0.23, 1, 0.32, 1),
      scale 440ms cubic-bezier(0.23, 1, 0.32, 1),
      filter 320ms cubic-bezier(0.23, 1, 0.32, 1);
  }

  .case-study-div:hover .case-veil {
    opacity: 1;
    backdrop-filter: blur(16px) saturate(1.1) brightness(0.72);
    -webkit-backdrop-filter: blur(16px) saturate(1.1) brightness(0.72);
    transition:
      opacity 420ms cubic-bezier(0.23, 1, 0.32, 1),
      backdrop-filter 520ms cubic-bezier(0.23, 1, 0.32, 1),
      -webkit-backdrop-filter 520ms cubic-bezier(0.23, 1, 0.32, 1);
  }
}

/* A phone has no hover, so the pill would never arrive — the slot keeps its
   own line of text there instead. */
@media not all and (hover: hover) {
  .case-more-track {
    display: none;
  }
}


/* The caption under a shot, set as text rather than frozen into the picture.
   Three of the case-study images carried a typeset line inside the pixels —
   "User interface of the app before improvements" and the like — which does
   not follow the theme, does not scale, cannot be selected and went nearly
   invisible on the dark panel. The ink is split the way the original was: the
   lead-in sits back, the operative words come forward. */
.shot-caption {
  margin: 18px 0 0;
  color: var(--muted);
  font-size: 14px;
  line-height: 1.5;
  text-align: center;
  text-wrap: balance;
}

.shot-caption span {
  color: var(--ink);
}


/* Max drew a vertical rule between the tag and the year. What was there was a
   6px dot shipped as an SVG with its colour baked in — #15161C at 16% — so on
   the dark card it was invisible and in light it was nearly so. A rule in
   currentColor follows the theme and is the mark he actually asked for. */
.tag-rule {
  flex: none;
  width: 1px;
  height: 11px;
  background: currentColor;
  opacity: 0.3;
}


/* --- The number on a card -------------------------------------------------
   Max: the action moved onto the artwork, so this slot carries what the work
   actually did instead. Only where there is a real figure: the other three
   case studies say in writing that they have no usage number worth standing
   behind, and a card is not the place to start inventing one. */
.case-stat {
  display: flex;
  flex-direction: column;
  gap: 4px;
  margin-top: 24px;
}

.case-stat-value {
  color: var(--ink);
  font-family: Unbounded, sans-serif;
  font-size: 34px;
  line-height: 1.04;
  letter-spacing: -0.02em;
  /* The figure can change; the label under it should not shift when it does. */
  font-variant-numeric: tabular-nums;
}

.case-stat-label {
  max-width: 24ch;
  color: var(--muted);
  /* Display type is for the figure; the line under it is reading text. */
  font-family: Inter, sans-serif;
  font-size: 14px;
  line-height: 1.35;
  text-wrap: pretty;
}

@media screen and (max-width: 767px) {
  .case-stat-value {
    font-size: 28px;
  }

  /* Max: "maybe undo and make product design 2026 smaller instead." The stat
     went to one line at 20/13 to buy room on a 390 card; this buys it from the
     meta line instead, so the figure keeps its weight. Scoped to .tags — the
     same class labels the role and timeline boxes on a case study, and those
     are not what is tight. */
  [data-theme] .tags .text-uppercase {
    font-size: 12px;
  }
}


/* --- The artwork panel is a terminal field --------------------------------
   Max: "why all gradients blue now? let's have different colors for all" plus
   React Bits' Faulty Terminal for the panel itself. The four cards used to
   share the hero's blue, which made the work read as one thing seen four
   times; they are four hues again, and each is the colour that project
   actually is — Limitless lime, Kyrrex's charts blue, PropQuest violet,
   Xeenon's credits amber.

   The canvas sits under the screenshot and over the gradient. The gradient
   stays underneath on purpose: faulty-terminal.js removes its own canvas if
   WebGL2 is missing or a shader will not compile, and what is left has to be
   the panel we had rather than a black hole. */
.ft-canvas {
  position: absolute;
  inset: 0;
  z-index: 0;
  display: block;
  width: 100%;
  height: 100%;
  /* The panel's own corner is the card's; the field must not square it off. */
  border-radius: inherit;
  pointer-events: none;
}

/* The shot and the hover pill both ride above the field. */
.image-column img {
  position: relative;
  z-index: 1;
}

[data-ft] {
  /* Tuned down from the component's defaults: this runs behind a product
     screenshot, four at a time, not as a full-page hero. Curvature nearly off
     because the panel is a flat card rather than a CRT, and the glitch and
     flicker at about a third so the eye stays on the work. */
  --ft-time-scale: 0.22;
  --ft-scale: 1.45;
  --ft-digit-size: 1.3;
  --ft-scanline: 0.18;
  --ft-glitch: 0.35;
  --ft-flicker: 0.4;
  --ft-curvature: 0.08;
  --ft-vignette: 0.45;
  --ft-brightness: 0.82;
  --ft-glow: 0.5;
  --ft-mouse-strength: 0.25;
  --ft-mouse-glow: 0.6;
}

/* The per-project hue. Keyed to the card's own project, not its position:
   when two projects were removed the nth-child numbering shifted and every
   card after the gap inherited the hue of the one that used to sit there.
   --ft-tint has no sensible default either — the component falls back to
   #ffffff, which renders a white terminal field rather than degrading — so
   every card that exists needs a hue written here. */
/* Max: "and here can be purple bg". The two Limitless cards sit next to each
   other, so the creator flow keeps the product's lime and the profile takes
   the violet — the hue is what stops them reading as one piece of work seen
   twice. */
[data-project='limitless'] {
  --ft-tint: #9b6cff;
  --glow-a: #9b6cff;
  --glow-b: #4c1d95;
}

/* Max: "green bg". Both Limitless cards wear the product's own lime — the
   per-card hue separates projects, and these two are the same project. */
[data-project='creator'] {
  --ft-tint: #c6f24d;
  --glow-a: #c6f24d;
  --glow-b: #3f8f2f;
}

[data-project='kyrrex'] {
  --ft-tint: #4d8cff;
  --glow-a: #4d8cff;
  --glow-b: #1e3fd8;
}

[data-project='cooked'] {
  --ft-tint: #ff6b2c;
  --glow-a: #ff6b2c;
  --glow-b: #8f2d08;
}

[data-project='kaia'] {
  --ft-tint: #b6e61f;
  --glow-a: #b6e61f;
  --glow-b: #5f7d0a;
}


/* --- A film on a case study cover ------------------------------------------
   The cover still stays exactly where it was, underneath. It is the poster
   before the film starts, the fallback when the file will not play, and the
   thing header-contrast.js samples to decide whether the back arrow and the
   theme switch should be light or dark on this photograph — none of which a
   <video> can answer for. The film fades in over it once it is really
   running, so a slow connection shows the picture rather than a black box. */
.cover-video {
  display: block;
  width: 100%;
  height: 100%;
  object-fit: cover;
  object-position: 50% 50%;
  opacity: 0;
  transition: opacity 600ms cubic-bezier(0.23, 1, 0.32, 1);
}

.cover-video[data-playing] {
  opacity: 1;
}

@media (prefers-reduced-motion: reduce) {
  /* cover-video.js takes the autoplay attribute away as well; this is the
     half that does not depend on a script having run. */
  .cover-video {
    display: none;
  }
}


/* One frosted pane over the whole artwork half — the terminal field, the
   screenshot and the panel behind them all at once. It is opacity that
   animates, not the filter: at opacity 0 the browser skips the backdrop work
   entirely, which matters when four of these sit on one page. */
.case-veil {
  position: absolute;
  inset: 0;
  z-index: 2;
  pointer-events: none;
  opacity: 0;
  background: rgba(3, 17, 28, 0.3);
  /* The blur GROWS with the fade rather than arriving fully formed. A pane
     that is already frosted and then fades in is two pictures dissolving into
     each other; a blur that grows is one picture going soft, which is the
     thing actually being described. The list has to hold the same three
     functions in the same order in both states — a filter list interpolates
     function by function, and a list that changes length jumps. */
  backdrop-filter: blur(0px) saturate(1) brightness(1);
  -webkit-backdrop-filter: blur(0px) saturate(1) brightness(1);
  transition:
    opacity 300ms cubic-bezier(0.23, 1, 0.32, 1),
    backdrop-filter 300ms cubic-bezier(0.23, 1, 0.32, 1),
    -webkit-backdrop-filter 300ms cubic-bezier(0.23, 1, 0.32, 1);
}

/* No hover, no frosting — and no idle compositor layer for it either. */
@media not all and (hover: hover) {
  .case-veil {
    display: none;
  }
}

/* Reduced motion keeps the STATE and drops the MOVEMENT: the artwork still
   frosts and the pill still arrives, because those are what the hover means,
   but nothing travels, grows or chases the cursor. card-pointer.js bows out
   entirely under this query, so the pill stays at the middle. */
@media (prefers-reduced-motion: reduce) {
  .case-veil,
  .case-more,
  .case-study-div:hover .case-veil,
  .case-study-div:hover .case-more {
    transition: none;
  }

  .case-more,
  .case-study-div:hover .case-more {
    scale: 1;
    filter: none;
  }

  [data-theme] .case-study-div,
  [data-theme] .case-study-div:hover {
    scale: 1;
    transition: none;
  }
}


/* --- The overlay that keeps you on the page --------------------------------
   Max: "better modal, and better z index — maybe just this thing and the x
   button as in lmts modals but liquid glass style."

   So it is the embedded page and nothing else, with a round glass close
   hanging off the top-right corner the way Limitless' own modals do. The
   browser chrome that used to sit on top of it has gone; it belongs to the
   INLINE demo, where a frame says "this is really running", and on a
   full-screen overlay it was just a second title bar.

   On the z-index: Webflow ships .navbar at 1000, so at 200 this sat politely
   underneath the avatar and the theme switch. The layers on this site are
   scrim 50, dock 60, Webflow's header 1000, and this on top of all of it. */
.frame-modal {
  position: fixed;
  inset: 0;
  z-index: 2000;
  display: grid;
  place-items: center;
  padding: clamp(14px, 3vw, 44px);
}

.frame-modal[hidden] {
  display: none;
}

.frame-modal-scrim {
  position: absolute;
  inset: 0;
  background: rgba(3, 17, 28, 0.66);
  backdrop-filter: blur(10px) saturate(1.2);
  -webkit-backdrop-filter: blur(10px) saturate(1.2);
  opacity: 0;
  /* Out with the window, in on the window's timing — see .frame-modal-window. */
  transition: opacity 150ms cubic-bezier(0.23, 1, 0.32, 1);
}

.frame-modal[data-open] .frame-modal-scrim {
  transition: opacity 220ms cubic-bezier(0.23, 1, 0.32, 1);
}

/* The window carries the shadow and the close button; the clip inside carries
   the radius. They cannot be the same element — overflow: hidden would cut the
   button off at the corner it is supposed to hang over. */
.frame-modal-window {
  position: relative;
  width: min(1180px, 100%);
  height: min(860px, 100%);
  opacity: 0;
  /* The closed state IS the exit, because closing is removing [data-open] —
     so the short timing belongs here and the entrance belongs on the open rule
     below. It used to be one declaration doing both, which made dismissing
     take exactly as long and travel exactly as far as opening: a close that
     mirrors its open reads as a second event, one that gets out of the way
     reads as the same event ending. Travel comes down with it, 10px to 4px and
     0.97 to 0.985 — the same split .ava-tip already makes. */
  scale: 0.985;
  translate: 0 4px;
  transition:
    opacity 150ms cubic-bezier(0.23, 1, 0.32, 1),
    scale 180ms cubic-bezier(0.23, 1, 0.32, 1),
    translate 180ms cubic-bezier(0.23, 1, 0.32, 1);
}

/* The prototype wants room; the booking card does not, and a bigger box around
   it is just more black. */
.frame-modal[data-wide] .frame-modal-window {
  width: min(1680px, 100%);
  height: 100%;
}

/* The card is the rounded, clipped thing; the window around it is only a
   positioning context, because overflow: hidden would cut the close button off
   at the very corner it is meant to hang over. */
.frame-modal-card {
  display: flex;
  flex-direction: column;
  width: 100%;
  height: 100%;
  overflow: hidden;
  border-radius: 20px;
  background: var(--page);
  box-shadow:
    inset 0 0 0 1px var(--line),
    0 2px 6px rgba(3, 17, 28, 0.18),
    0 40px 90px -30px rgba(3, 17, 28, 0.8);
}

.frame-modal-clip {
  position: relative;
  flex: 1;
  overflow: hidden;
}

/* Max: "on this one, keep the head we had." The chrome belongs to the
   prototype and not to the booking: a frame around a live application says it
   is really running, while on a booking card it was a second title bar saying
   nothing. So it is opt-in, per opener. */
.frame-modal-bar {
  display: none;
}

.frame-modal[data-chrome] .frame-modal-bar {
  position: relative;
  display: flex;
  flex: none;
  align-items: center;
  gap: 10px;
  height: 44px;
  padding: 0 14px;
  background: color-mix(in srgb, var(--ink) 5%, var(--surface));
  box-shadow: inset 0 -1px 0 var(--line);
}

.frame-modal[data-open] .frame-modal-scrim {
  opacity: 1;
}

.frame-modal[data-open] .frame-modal-window {
  opacity: 1;
  scale: 1;
  translate: 0 0;
  /* The entrance: longer than the exit above, and it opens from further away. */
  transition:
    opacity 220ms cubic-bezier(0.23, 1, 0.32, 1),
    scale 300ms cubic-bezier(0.23, 1, 0.32, 1),
    translate 300ms cubic-bezier(0.23, 1, 0.32, 1);
}

/* The house glass, hanging off the corner. */
.frame-modal-close {
  position: absolute;
  top: -14px;
  right: -14px;
  display: grid;
  place-items: center;
  width: 44px;
  height: 44px;
  padding: 0;
  border: 0;
  border-radius: 999px;
  background-color: var(--glass-bg);
  color: var(--ink);
  cursor: pointer;
  backdrop-filter: var(--glass-blur) brightness(var(--dock-floor));
  -webkit-backdrop-filter: var(--glass-blur) brightness(var(--dock-floor));
  box-shadow:
    inset 0 1px 0 var(--glass-hi),
    inset 0 -1px 0 var(--glass-lo),
    var(--glass-cast);
  transition:
    background-color 180ms cubic-bezier(0.23, 1, 0.32, 1),
    scale 140ms cubic-bezier(0.23, 1, 0.32, 1);
}

.frame-modal-close svg {
  width: 19px;
  height: 19px;
}

@media (hover: hover) and (pointer: fine) {
  .frame-modal-close:hover {
    background-color: var(--glass-bg-strong);
  }
}

.frame-modal-close:active {
  scale: 0.92;
}

.frame-modal-frame {
  display: block;
  width: 100%;
  height: 100%;
  border: 0;
  background: var(--page);
}

/* The page behind cannot scroll while this is up. Fixing the body and holding
   the offset is what keeps it from jumping to the top on open. */
body[data-frame-locked] {
  position: fixed;
  width: 100%;
  overflow: hidden;
}

@media (prefers-reduced-motion: reduce) {
  .frame-modal-scrim,
  .frame-modal-window {
    transition: none;
  }

  .frame-modal-window {
    scale: 1;
    translate: 0 0;
  }
}


/* --- A live prototype, inline on a case study ------------------------------
   This is where the browser chrome belongs. A frame baked into a SCREENSHOT
   says the picture is a capture rather than a piece of work; a frame around a
   LIVE embed says the opposite, which is the only reason to put one here.

   The embedded page is a desktop application and it does not fit in 900px, so
   it is rendered at its own width and scaled down to the frame — the same
   trick a device preview uses. demo-scale is written by frame-modal.js from
   the measured stage, because CSS cannot divide one element's width by a
   constant. */
.demo-wrap {
  position: relative;
  max-width: var(--media);
  /* No margin of its own — .content-section owns the gap. The handwritten
     label used to hang 46px ABOVE this box on a 72px margin, which left three
     pixels between it and the paragraph above; it now sits inside the box's
     own top padding, so the gap above the demo is the same gap as everywhere
     else and the label has real room. */
  margin-inline: auto;
  padding-top: 52px;
}

/* Max: "pls higher screen for demo." The frame is 1120 wide now, so 700 puts
   it at 1.6:1 — a laptop screen — instead of the letterbox 560 left it at.
   Height does not touch the scale factor (that is stage width over the
   prototype's 1280 design width), so this simply shows more of the page. */
.demo-frame {
  position: relative;
  display: flex;
  flex-direction: column;
  height: 700px;
  overflow: hidden;
  /* 24, not 18: on the Limitless page this is a 1120px surface plate followed
     by three more of exactly the same width, and those are 24. The phone value
     stays 18 — see the max-width: 767px block below. */
  border-radius: 24px;
  background: var(--surface);
  box-shadow:
    inset 0 0 0 1px var(--line),
    0 2px 6px rgba(3, 17, 28, 0.1),
    0 30px 60px -28px rgba(3, 17, 28, 0.5);
}

.demo-stage {
  position: relative;
  flex: 1;
  overflow: hidden;
  background: var(--page);
}

.demo-stage iframe {
  width: var(--demo-w, 1280px);
  height: var(--demo-h, 734px);
  border: 0;
  transform: scale(var(--demo-scale, 0.7));
  transform-origin: top left;
}

/* Max's own note on the thing, in his own hand — the one place on this site
   that is not set in Unbounded or Inter, because it is an annotation about the
   page rather than type belonging to it. */
.demo-label {
  position: absolute;
  top: 6px;
  right: 18px;
  display: block;
  color: var(--faint);
  font-family: Caveat, cursive;
  font-size: 23px;
  line-height: 1;
  white-space: nowrap;
  pointer-events: none;
}

.demo-arrow {
  position: absolute;
  top: 16px;
  right: 100%;
  width: 62px;
  height: 34px;
  margin-right: 6px;
}

.demo-bar {
  position: relative;
  display: flex;
  flex: none;
  align-items: center;
  gap: 10px;
  height: 44px;
  padding: 0 12px;
  background: color-mix(in srgb, var(--ink) 5%, var(--surface));
  box-shadow: inset 0 -1px 0 var(--line);
}

.demo-dots {
  display: flex;
  flex: none;
  gap: 6px;
}

.demo-dots i {
  width: 10px;
  height: 10px;
  border-radius: 999px;
  background: color-mix(in srgb, var(--ink) 16%, transparent);
}

.demo-url {
  position: absolute;
  left: 50%;
  translate: -50%;
  max-width: 54%;
  overflow: hidden;
  color: var(--faint);
  font-size: 12px;
  line-height: 1;
  white-space: nowrap;
  text-overflow: ellipsis;
}

.demo-expand {
  display: inline-flex;
  flex: none;
  align-items: center;
  gap: 7px;
  height: 28px;
  margin-left: auto;
  padding: 0 11px;
  border: 0;
  border-radius: 999px;
  background: color-mix(in srgb, var(--ink) 7%, transparent);
  color: var(--muted);
  font-family: Inter, sans-serif;
  font-size: 12px;
  font-weight: 500;
  cursor: pointer;
  transition:
    background-color 180ms cubic-bezier(0.23, 1, 0.32, 1),
    color 180ms cubic-bezier(0.23, 1, 0.32, 1),
    scale 140ms cubic-bezier(0.23, 1, 0.32, 1);
}

.demo-expand svg {
  width: 14px;
  height: 14px;
}

@media (hover: hover) and (pointer: fine) {
  .demo-expand:hover {
    background: color-mix(in srgb, var(--ink) 12%, transparent);
    color: var(--ink);
  }
}

.demo-expand:active {
  scale: 0.94;
}

@media screen and (max-width: 767px) {
  .demo-wrap {
    /* Max: "on mobile = align all images." The frame carried no side padding,
       so it started at the text's 16px while every seated shot started at 32.
       The seat is what the other images stand on; the frame stands on this. */
    padding-top: 44px;
    padding-inline: var(--shot-pad);
  }

  .demo-frame {
    border-radius: 18px;
    /* Taller on a phone, because the embedded page is showing its own mobile
       layout at 1:1 rather than a desktop page shrunk to a postage stamp. */
    height: 74vh;
    min-height: 460px;
  }

  .demo-label {
    top: 4px;
    right: 6px;
    font-size: 19px;
  }

  .demo-arrow {
    width: 46px;
    height: 26px;
    top: 12px;
  }

  .demo-url {
    display: none;
  }

  .frame-modal-close {
    top: -10px;
    right: -6px;
    width: 40px;
    height: 40px;
  }
}


/* --- About Me ---------------------------------------------------------------
   Max picked the underline sweep out of five. It is the same recipe the inline
   brand links on About use — two gradient layers, a faint one always at full
   width and a solid one that grows from the left — so the hero's one link and
   the links inside the running copy are the same object at two sizes.

   The arrow is split off into its own span so the rule underlines the words
   and not the glyph, and so the glyph can travel without dragging the
   underline along with it. Webflow's `a` ships a dashed bottom border that it
   paints on hover; that has to go or there are two underlines a pixel apart. */
[data-theme] .button-text {
  display: inline-flex;
  align-items: center;
  gap: 10px;
  border-bottom: 0;
  text-decoration: none;
}

[data-theme] .button-text:hover {
  text-decoration: none;
}

.btn-label {
  background-image:
    linear-gradient(currentColor, currentColor),
    linear-gradient(
      color-mix(in srgb, currentColor 22%, transparent),
      color-mix(in srgb, currentColor 22%, transparent));
  background-repeat: no-repeat;
  background-position: 0 100%, 0 100%;
  background-size: 0 2px, 100% 2px;
  padding-bottom: 5px;
  transition: background-size 320ms cubic-bezier(0.23, 1, 0.32, 1);
}

.btn-arrow {
  display: inline-block;
  transition: translate 320ms cubic-bezier(0.23, 1, 0.32, 1);
}

@media (hover: hover) and (pointer: fine) {
  .button-text:hover .btn-label {
    background-size: 100% 2px, 100% 2px;
  }

  /* It travels the same distance the underline has just covered in feel, not
     in pixels — far enough to read as a step toward the page it opens. */
  .button-text:hover .btn-arrow {
    translate: 6px 0;
  }
}

/* 27px tall at 390 — the home hero's CTA and, on the 404, the only escape
   route that is not the header or the dock. The pseudo takes it over 40
   without touching the type or the underline reveal. The horizontal growth
   stays at 4px on purpose: the dock is fixed and can sit over this link at
   some scroll positions, and a taller box would make that worse. */
.button-text {
  position: relative;
}

.button-text::after {
  content: '';
  position: absolute;
  inset: -7px -4px;
}

.button-text:active {
  scale: 0.97;
}

@media (prefers-reduced-motion: reduce) {
  .btn-label,
  .btn-arrow {
    transition: none;
  }

  .button-text:active {
    scale: 1;
  }
}


/* --- Dock overflow ----------------------------------------------------------
   Max: "on mobile — contact button, email and tg, then the dots button and a
   dropdown on click with LinkedIn and CV." The pill plus four circles is
   wider than a 390 screen can hold, so the two that are reference links
   rather than ways to reach him fold behind one button. Above 768 nothing
   folds and the dots button is not there at all. */
.dock-more-wrap {
  display: none;
  position: relative;
}

.dock-menu {
  position: absolute;
  right: 0;
  bottom: calc(100% + 10px);
  z-index: 1;
  display: flex;
  flex-direction: column;
  gap: 2px;
  min-width: 164px;
  padding: 6px;
  border-radius: 20px;
  background: var(--glass-bg);
  backdrop-filter: brightness(var(--dock-floor)) var(--glass-blur);
  -webkit-backdrop-filter: brightness(var(--dock-floor)) var(--glass-blur);
  box-shadow:
    inset 0 1px 0 var(--glass-hi),
    inset 0 -1px 0 var(--glass-lo),
    var(--glass-cast);
  /* It belongs to the button it opens from, so it grows out of that corner
     rather than out of its own middle. Never from scale 0 — nothing in the
     world appears out of nothing. */
  transform-origin: 100% 100%;
  opacity: 0;
  scale: 0.94;
  translate: 0 6px;
  /* Closed is the exit state, so the shorter timing lives here and the
     entrance is on [data-open] below. */
  transition:
    opacity 120ms cubic-bezier(0.23, 1, 0.32, 1),
    scale 140ms cubic-bezier(0.23, 1, 0.32, 1),
    translate 140ms cubic-bezier(0.23, 1, 0.32, 1);
  /* Closed it is only transparent, not gone — so its 164px box went on taking
     every click in that corner of the page. Measured at 600: the slider's
     fourth dot sat under it and was dead, with the stack reading
     dock-menu > slider-dot. An invisible pane must not be in the way. */
  pointer-events: none;
}

.dock-menu[data-open] {
  opacity: 1;
  scale: 1;
  translate: 0 0;
  pointer-events: auto;
  transition:
    opacity 180ms cubic-bezier(0.23, 1, 0.32, 1),
    scale 180ms cubic-bezier(0.23, 1, 0.32, 1),
    translate 180ms cubic-bezier(0.23, 1, 0.32, 1);
}

.dock-menu-item {
  display: flex;
  align-items: center;
  gap: 10px;
  /* Webflow's `a` ships `border: 1px #000; border-bottom-style: dashed`, and an
     element that does not override it wears a dashed black line — curved into
     an arc here, because the row carries a 14px radius. Same removal as the
     contact pill and the About Me link. */
  border-bottom-style: none;
  /* 44px tall: a row in a menu is a touch target before it is a row. */
  min-height: 44px;
  padding: 0 14px 0 12px;
  border-radius: 14px;
  color: var(--ink);
  font-family: Inter, sans-serif;
  font-size: 15px;
  font-weight: 500;
  letter-spacing: -0.01em;
  text-decoration: none;
  transition: background-color 160ms cubic-bezier(0.23, 1, 0.32, 1);
}

.dock-menu-item svg {
  flex: none;
  width: 20px;
  height: 20px;
}

.dock-menu-item:active {
  background: color-mix(in srgb, var(--ink) 10%, transparent);
}

@media (hover: hover) and (pointer: fine) {
  .dock-menu-item:hover {
    background: color-mix(in srgb, var(--ink) 8%, transparent);
  }
}

.dock-more[aria-expanded='true'] {
  background: var(--glass-bg-strong, var(--glass-bg));
}

@media screen and (max-width: 767px) {
  .dock-fold {
    display: none;
  }

  .dock-more-wrap {
    display: block;
  }
}

@media (prefers-reduced-motion: reduce) {
  .dock-menu {
    transition: none;
    scale: 1;
    translate: 0 0;
  }
}


/* --- 404 --------------------------------------------------------------------
   Max: "a much better 404 than this default one from webflow — same gradient,
   header and floating buttons." So it is the home page's own hero rather than
   a drawing of one: the same field, the same header, the same dock, and the
   type in the site's voice. Nothing here is a special case except the height —
   with no work section under it the hero has to hold the page on its own. */
/* Max: "just center text better pls." It was not centred at all. Webflow's
   .hero-section is a COLUMN flex with justify-content: flex-end — the home
   hero deliberately sits its type at the bottom of a tall field. On a column
   axis align-items is the HORIZONTAL one, so the align-items: center written
   here did nothing vertical and the block stayed pinned to the bottom edge:
   measured 207.5px below the middle of the section. Centring a column is
   justify-content.
   And the 404 has nothing under it, so it takes the whole window rather than
   stopping at 760px and leaving dead page between the type and the dock.
   The padding is symmetric — which is what makes "centre" the real centre —
   and big enough to clear the floating header (88px) and the dock (72px off
   the bottom) on a short window. Because the band between those two is itself
   bottom-heavy, a true centre lands the block ~12px above the middle of the
   clear space, which is the lift a block of type wants anyway. */
[data-theme] .not-found-section {
  display: flex;
  justify-content: center;
  min-height: 100svh;
  padding-block: 120px;
}

[data-theme] .not-found-section .hero-container {
  width: 100%;
}

/* --- A slider for screens that belong together -----------------------------
   Max: "here's let's do slider pls." Four Kyrrex Pay screens where there was
   one — the same product seen four ways, so they take one slot rather than
   four scroll-lengths of page.

   It is a SCROLLER, not a carousel: the track is the browser's own horizontal
   scroll with scroll-snap doing the stopping, so a trackpad swipe, a
   shift-wheel and a drag all work with no script. shot-slider.js only adds
   the buttons and the dots. With no script at all the slides are still there
   and still scrollable — which is why the controls are hidden until it says
   it is live. */
.shot-slider {
  position: relative;
  max-width: var(--media);
  /* No margin-top of its own — .content-section owns the gap. */
  margin-inline: auto;
  --slider-arrow: 44px;
}

/* The track and the two arrows. A box of its own, so the arrows centre on the
   picture rather than on the picture plus the dot row under it. */
.slider-stage {
  position: relative;
  /* The track's negative margins are there to give its own padding back to
     the layout. Without a block formatting context here they would collapse
     through this box instead and shift the whole slider up by 24px, leaving
     the same 24px dangling under the dots. */
  display: flow-root;
}

.slider-track {
  display: flex;
  gap: 16px;
  overflow-x: auto;
  scroll-snap-type: x mandatory;
  scrollbar-width: none;
  -webkit-overflow-scrolling: touch;
  /* A scroll container clips on both axes, so the shot's own cast would be
     sheared off at the top and bottom edges. The padding gives the shadow
     room inside the scrollport and the negative margin gives the space back
     to the layout, so the slider is still exactly as tall as its picture. */
  padding: 24px 0;
  margin: -24px 0;
}

.slider-track::-webkit-scrollbar {
  display: none;
}

.slider-slide {
  flex: 0 0 100%;
  scroll-snap-align: center;
}

/* Seated exactly like every other shot on the site: same radius, same cast,
   same hairline — pure black on light and pure white on dark, never a tinted
   neutral. */
.slider-slide img {
  display: block;
  width: 100%;
  height: auto;
  border-radius: 12px;
  outline: 1px solid rgba(0, 0, 0, 0.1);
  outline-offset: -1px;
  box-shadow:
    0 1px 2px rgba(3, 17, 28, 0.08),
    0 20px 44px -20px rgba(3, 17, 28, 0.38);
}

[data-theme='dark'] .slider-slide img {
  outline-color: rgba(255, 255, 255, 0.1);
}

.slider-bar {
  display: none;
  align-items: center;
  justify-content: center;
  /* The same gap a caption keeps from the shot above it. */
  margin-top: 18px;
}

[data-slider-live] .slider-bar {
  display: flex;
}

.slider-step {
  display: none;
  position: absolute;
  top: 50%;
  translate: 0 -50%;
  place-items: center;
  width: var(--slider-arrow);
  height: var(--slider-arrow);
  padding: 0;
  border: 0;
  border-radius: 999px;
  background-color: var(--glass-bg);
  color: var(--ink);
  cursor: pointer;
  backdrop-filter: var(--glass-blur);
  -webkit-backdrop-filter: var(--glass-blur);
  box-shadow:
    inset 0 1px 0 var(--glass-hi),
    inset 0 -1px 0 var(--glass-lo),
    var(--glass-cast);
  transition:
    background-color 180ms cubic-bezier(0.23, 1, 0.32, 1),
    opacity 180ms cubic-bezier(0.23, 1, 0.32, 1),
    scale 140ms cubic-bezier(0.23, 1, 0.32, 1);
}

[data-slider-live] .slider-step {
  display: grid;
}

/* Max marked where these go: centred on the panel's own edge, half on the
   seat and half off it. Measured from the picture rather than the panel, so
   the arrow stays on that edge whatever the seat is. */
.slider-step[data-slider-prev] {
  left: calc(var(--shot-pad) * -1 - var(--slider-arrow) / 2);
}

.slider-step[data-slider-next] {
  right: calc(var(--shot-pad) * -1 - var(--slider-arrow) / 2);
}

.slider-step svg {
  width: 19px;
  height: 19px;
}

.slider-step[disabled] {
  opacity: 0.35;
  cursor: default;
}

@media (hover: hover) and (pointer: fine) {
  .slider-step:not([disabled]):hover {
    background-color: var(--glass-bg-strong);
  }
}

.slider-step:not([disabled]):active {
  scale: 0.92;
}

.slider-dots {
  display: flex;
  align-items: center;
  gap: 8px;
}

.slider-dot {
  width: 7px;
  height: 7px;
  /* 7px of paint, a 15x31 target. `content-box` plus padding grows the hit box
     without moving the dot, and `background-clip` keeps the paint on the
     content box — see .slider-bar below for why it needed to grow at all.
     The colour is the `background-color` LONGHAND, and the clip is declared
     after it, both on purpose: the `background` shorthand resets
     background-clip to border-box, so written the other way round the dot
     paints its whole hit box and the row becomes four fat bars. */
  box-sizing: content-box;
  padding: 12px 4px;
  border: 0;
  border-radius: 999px;
  background-color: color-mix(in srgb, var(--ink) 22%, transparent);
  background-clip: content-box;
  cursor: pointer;
  transition:
    background-color 200ms cubic-bezier(0.23, 1, 0.32, 1),
    width 240ms cubic-bezier(0.23, 1, 0.32, 1),
    scale 140ms cubic-bezier(0.23, 1, 0.32, 1);
}

/* The one you are on is a bar rather than a brighter dot — a length reads at a
   glance where a shade does not. */
.slider-dot[data-on] {
  width: 20px;
  /* Longhand here too, or this rule resets the clip the base rule just set. */
  background-color: var(--ink);
}

/* The four dots looked live — pointer cursor, tab stop, the bar growing on the
   one you are on — and no click or tap ever reached them. The track carries
   vertical padding to keep the shot's cast inside the scrollport, and that
   padding box is painted over the dot row: measured, elementFromPoint at every
   dot's centre returned .slider-track at all four widths. On a phone, where
   the arrows are hidden, that left the component with no working control at
   all. The bar lifts out of the track, and the dots take a real 15x31 box by
   padding that the row gives straight back to the layout. */
.slider-bar {
  position: relative;
  z-index: 1;
}

.slider-dots {
  margin-block: -12px;
}

/* 0.9 rather than the usual 0.96: on a 7px dot 0.96 is a quarter of a pixel.
   It scales the padded box, so the bar's own width transition is untouched. */
.slider-dot:active {
  scale: 0.9;
}

@media (prefers-reduced-motion: reduce) {
  .slider-track {
    scroll-behavior: auto;
  }

  .slider-dot,
  .slider-step {
    transition: none;
  }

  /* Without this the press would still happen, just instantly — which is a
     jump rather than less motion. */
  .slider-dot:active {
    scale: 1;
  }
}

@media screen and (max-width: 767px) {
  .slider-slide img {
    border-radius: 8px;
  }

  /* No gutter left to stand in, and a thumb swipes the track anyway. */
  [data-slider-live] .slider-step {
    display: none;
  }

  .slider-bar {
    margin-top: 14px;
  }
}


/* --- Phone: the card's hairline must not cross the artwork -----------------
   Max: "on mobile see this white border, pls fix."

   The card's edge is `1px solid rgba(255,255,255,.08)`. Over the card's own
   `--page` it is the quiet outline it is meant to be; over the near-black
   artwork it is a bright stripe, because the same 8% white lands on #0a0a0a
   instead of #121212. Measured rather than reasoned: setting the card's
   border-color to transparent removes the line completely and nothing else
   changes, so it is the border and not the shot, the panel or the field.

   Stacked on a phone the artwork spans the whole card and meets three of its
   edges, which is why it reads as a stripe across the card here and not at
   1440. The fix is an inset ring instead of a border: an inset shadow paints
   above the background but BELOW content, so the artwork covers it on its own
   half while the text half keeps its outline. A negative margin on the artwork
   was tried first and is wrong — the column is a grid item, so the -1px moved
   its left edge but did not widen it, leaving the right edge 2px short of the
   card and the border still showing there. Measured, not assumed.

   border-color goes transparent rather than the border going away, so the 1px
   stays in the layout and nothing reflows. */
@media screen and (max-width: 767px) {
  [data-theme] .case-study-div {
    border-color: transparent;
    box-shadow:
      inset 0 0 0 1px var(--line),
      var(--card-cast);
  }

  /* Max: "still somehow have this white border appearing on scroll or smth."
     Measured at 390: the artwork's box sits exactly 1px inside the card on all
     four sides, because `.case-study-div` keeps a 1px border whose colour went
     transparent (it stays in the layout so nothing reflows) — and the inset
     ring that replaced that border is painted in precisely that 1px strip. The
     artwork covers the card's own background but not its border box, so the
     ring reads as a bright hairline along the bottom and up both corners,
     drifting in and out as sub-pixel scroll positions round it.

     So the shot spills 1px in every direction and the card's own
     `overflow: hidden` clips it back to the radius. A negative margin on
     `.image-column` cannot do this — it is a grid item, so -1px moves its edge
     without widening it, which is the trap written up under the inset-ring
     rule. On the img it is a straight box change and both edges move. */
  [data-theme] .image-column img {
    width: calc(100% + 2px);
    height: calc(100% + 2px);
    margin: -1px;
  }

  /* Max: "just a bit less corner radius on mobile here on all cards", on a
     crop with no card edge in it at all — every pixel of it is inside the
     artwork, and the only curve in frame is the screenshot's own top-left.

     That corner is CSS, not the bitmap: the shot carries about a 15px corner
     of its own at 1880px wide, which renders under 3px once the artwork is
     358px, while `.image-column img` draws a 12px one on top of it. Measured
     off his crop against the window's traffic lights, the curve is roughly
     three times what the baked one could be — so what reads as "too round" is
     this number, and on a phone it is 12px of a 358px-wide shot where at 1440
     it is 12 of 417. The card's own 24px corner is not in his picture and has
     not moved. */
  [data-theme] .image-column img {
    border-radius: 8px 0 0 0;
  }
}

/* --- Phone: the header controls come down a size --------------------------
   Max: "this buttons tabs should be smaller on mobile." At 52px the avatar and
   the theme switch are 146px of a 390px screen before the gap, and they sit
   over the hero rather than beside anything that justifies the weight.

   52 → 44, and every part that adds up to it moves together, or the pair stops
   matching: the pane is one of a pair with the theme switch and both are pinned
   on purpose. The switch is 34px opts + 5px padding = 44, and its thumb travels
   one opt plus the 2px gap, so 36. The avatar is sized by its contents, so BOTH
   of them have to come down — the memoji to 38 and the photograph to 32, which
   keeps the 3px-a-side the photograph needs to stop filling the pane edge to
   edge. Missing the photograph is what left the avatar at 46 against the
   switch's 44 on the first pass; it carries its own (0,3,0) rule and the memoji
   selector does not reach it.

   Written at the end of the file because these rules carry the same specificity
   as the ones they override and a media query adds none — above them they would
   lose silently. */
@media screen and (max-width: 767px) {
  [data-theme] .ava,
  [data-theme] .back-arrow {
    padding: 3px;
  }

  [data-theme] .sticker-1 {
    width: 38px;
    height: 38px;
  }

  [data-theme] .ava .ava-photo {
    width: 32px;
    height: 32px;
  }

  [data-theme] .back-arrow {
    width: 44px;
    height: 44px;
  }

  .back-arrow-glyph {
    width: 32px;
    height: 32px;
  }

  .theme-switch {
    padding: 5px;
  }

  .theme-switch-thumb {
    top: 5px;
    left: 5px;
    width: 34px;
    height: 34px;
  }

  [data-theme='dark'] .theme-switch-thumb {
    transform: translateX(36px);
  }

  [data-theme] .theme-opt {
    width: 34px;
    height: 34px;
  }
}


/* --- The project's mark on the cover --------------------------------------
   Max: "add logo icon here like this pls" — pointing at the bottom-left of a
   case-study cover, in line with the headline underneath.

   It hangs over the cover's bottom edge rather than sitting inside it, so it
   reads as an object laid on the picture rather than a corner logo printed
   into it — and the overlap is what ties the cover to the page below it.

   `--gutter` mirrors Webflow's own `.content-section` padding (64 / 40 / 16
   across its breakpoints) because that padding is what sets the column the
   headline starts in, and webflow.css is never edited — so the number is
   copied here under its own name rather than being the source of truth. Change
   one and change the other. */
:root {
  --gutter: 64px;
}

@media screen and (max-width: 991px) {
  :root {
    --gutter: 40px;
  }
}

@media screen and (max-width: 479px) {
  :root {
    --gutter: 16px;
  }
}

/* The positioning context is the SECTION, not the container: the container
   is `overflow: hidden` because it has to clip the cover film to its own box,
   and a mark that hangs over the bottom edge is exactly what that clips off.
   Measured — the tile was cut in half at the cover's edge on the first pass. */
/* --- The cover is sized for the film it holds -----------------------------
   Max: "let's make this first section a bit bigger, so the video fits better
   on desktop."

   Webflow pinned `.cover-image-container` to a flat 520px at every desktop
   width, and the film is 1920x1080. Measured at 520: the box is 2.77:1 at 1440
   and 3.32:1 at 1728, so `object-fit: cover` was showing 64% and 53% of the
   picture — which is why the headline inside the film was cut off at the top
   and the pack card at the bottom.

   Height tracks width instead of being flat, so the crop is the same on every
   screen rather than a different one per width. The number is the second pass:
   50vw capped at 760 was too much — Max: "I think it's too big now, content
   below is almost not visible", and at 760 on a ~1025px window the headline is
   the only thing under the fold. 46vw capped at 660 lands between the two: at
   1440 the box is 2.18:1 and shows 81% of the film against 64% before, and
   there is still 240px of page under it on a 900px window.
   Scoped to 768 and up — below that the box is already near the film's own
   shape (1.95:1 at 390, 91% shown) and the shorter Webflow heights stand. */
@media screen and (min-width: 768px) {
  [data-theme] .cover-image-container {
    height: min(46vw, 660px);
  }
}

[data-theme] .cover-image-section {
  position: relative;
}

[data-theme] .cover-logo {
  position: absolute;
  /* Max: "move here". It lines up with the HEADLINE, not with the page gutter.
     The text column is centred and capped at --column, so past about 1030px
     the headline starts well inside the gutter and a tile pinned to the gutter
     drifts away from it. This is the same sum the column does: the gutter
     until the column starts centring, the centred edge after that. */
  left: max(var(--gutter), calc((100% - var(--column)) / 2));
  /* Max: "maybe get logo a bit lower so it sits right in the middle." Half the
     tile above the cover's edge and half below it, so `bottom` is always minus
     half the side — 40 of 80 here, 32 of 64 and 28 of 56 below. It was -24,
     which put 56 of the tile on the picture and 24 off it and read as hung
     rather than seated. The 4px ring is symmetric, so centring the box centres
     the 88px object you actually see. */
  bottom: -40px;
  z-index: 1;
  width: 80px;
  height: 80px;
  /* Max: "add 4px border same a bg" — a ring in the page colour, so the mark
     reads as punched out of the cover rather than laid on top of it. The radius
     is a quarter of the side because both marks are drawn rx=12 on a 48 grid,
     and the ring only hugs the artwork if it matches. */
  border-radius: 20px;
  box-shadow: 0 0 0 4px var(--page);
}

@media screen and (max-width: 991px) {
  [data-theme] .cover-logo {
    bottom: -32px;
    width: 64px;
    height: 64px;
    border-radius: 16px;
  }
}

@media screen and (max-width: 479px) {
  [data-theme] .cover-logo {
    bottom: -28px;
    width: 56px;
    height: 56px;
    border-radius: 14px;
  }
}


/* --- A work card's title is capitalized like every other headline ----------
   Max: "please note titles are capitalized."

   Case on this site comes from CSS — `.heading-1` and `.heading-2` carry
   `text-transform: capitalize` in webflow.css, which is why the hero reads
   "I Design And Build Products With AI". `.text-head` never did, so a card
   title rendered exactly as typed, and the five of them had drifted into
   three different cases: Limitless hand-typed in title case, Kyrrex, cooked,
   Kaia and the creator flow all in sentence case.

   The rule goes here rather than the copy being retyped, so the source stays
   sentence case like the rest of the site and there is one place that decides. */
[data-theme] .text-head {
  text-transform: capitalize;
  /* The card titles were the only display headings on the site breaking their
     own lines unbalanced, so a two-line title put a full line over one orphan
     word — "Trader Portfolio Redesign For / Limitless". `.heading-1/2/3` have
     balanced since the 404 was left with a single word on its last line; this
     is the same rule reaching the one heading it had missed. */
  text-wrap: balance;
}


/* --- Every work card is the same width ------------------------------------
   Max: "the spacing is different and corner radius, do them the same way so it
   look identical."

   The cause was not the artwork. `.collection-list` AND `.collection-item` are
   both flex COLUMNS with `align-items: center`, and on a column flex the cross
   axis is the horizontal one — so each card was sized to its own content and
   centred rather than stretched. Both levels have to be answered: stretching
   only the list widened the item to 358 and left the card inside it at 302.
   Measured at 390: cooked rendered 302px wide and Kaia 358px, sitting in the
   same list at the same breakpoint. The artwork column is `aspect-ratio: 5/4`
   off that width, and the screenshot inside it is `object-fit: cover`, so a
   narrower card scaled its whole mockup down — the browser window, its padding
   from the panel edge and its corner radius all shrank by the same ~16%. That
   is the difference between the two crops.

   Stretching costs nothing at 1440: `.case-study-div` is capped at 900px, so
   the cards were already landing on the same width there, which is why this
   only ever showed on a phone. */
[data-theme] .collection-list,
[data-theme] .collection-item {
  align-items: stretch;
}


/* --- The heading over a group of cards ------------------------------------
   Max: "let's remove tabs and just put these other card on bottom of product
   cards, but add title to them, like other projects."

   The works section centres its children, so a shrink-wrapped h2 lands in the
   middle of a column of left-aligned cards — measured, 418px in against a card
   edge at 55. It takes the card column's own width and a left edge instead, the
   same correction the tab row needed. The gap above is --block-gap, the number
   the rest of the page already uses between two blocks. */
[data-theme] .work-group-title {
  /* Max: "add live divider and more space between pls", then "just other right
     here pls" — struck through the heading and wrote the word on the rule
     itself. So the label is not a title sitting above a group, it is a marker
     ON the divider, and the rule runs out of it to both edges of the column.

     That is why the word is `.text-uppercase` rather than a heading face: the
     site's meta labels — a card's discipline and year — are Unbounded
     uppercase at 14, and this is the same kind of thing. A heading would
     announce a second section where a marker just says the run changed.

     The works section lays its children out with a 32px gap of its own, so
     these margins are what gets ADDED to it: 72 above the rule and 56 below. */
  display: flex;
  align-items: center;
  gap: 16px;
  width: 100%;
  max-width: var(--column);
  margin: 40px auto 24px;
}

/* The two halves of the rule. They flex, so the word stays centred whatever
   the column does, and no second element is needed in the markup. */
[data-theme] .work-group-title::before,
[data-theme] .work-group-title::after {
  content: '';
  flex: 1 1 auto;
  height: 1px;
  background-color: var(--line);
}

/* Webflow's own .text-uppercase is align-self: flex-start, which in a centred
   flex row lifts the word off the line it is meant to sit on. */
[data-theme] .work-group-title .text-uppercase {
  flex: 0 0 auto;
  align-self: center;
}

@media screen and (max-width: 767px) {
  /* Same shape one notch down — 48 above, 40 below, in step with
     --block-gap's own 56 → 40 drop on a phone. */
  [data-theme] .work-group-title {
    gap: 12px;
    margin: 16px auto 8px;
  }
}


/* --- One framing for every card screenshot --------------------------------
   Max: "still the case on mobile = different spacing and corner radius?"

   He was right, and it was never the images — all five are byte-identical in
   framing, measured on the files, on the 940 variants and on the decoded
   bitmaps the page holds. Three of the five carried Webflow's `.image-full`,
   which is `object-fit: contain; object-position: 100% 100%` — contained and
   anchored BOTTOM-RIGHT, where the other two were anchored top-left. That is
   the 24px the mockup sat further in on those cards, and it took the apparent
   corner radius with it because contain scales the bitmap differently.

   The markup now uses one class on all five, and the framing is stated here
   rather than inherited from whichever Webflow class a card happened to get. */
[data-theme] .image-column img {
  object-fit: cover;
  object-position: 0% 0%;
}


/* --- Other projects are rows, not cards -----------------------------------
   Max, with a sketch: "logo   title   view button with icon to notify it's a
   new tab thing" and a "subtitle" under the title, plus "let's try making it
   smaller just like this."

   The two links under the rule are not case studies, they are places to go,
   and a 420px card each gave them the same weight as work that has a page of
   its own. As a row they read as a short list of destinations, which is what
   they are. Two things were asked rather than assumed: the screenshot goes
   entirely — his sketch has no picture in it — and the subtitle is the
   descriptor ("Vibe coded app", "Webflow developed") rather than the
   discipline and year, because on one line that slot has to say what the
   thing IS before it says when it was made.

   The row keeps the card's surface, ring and cast so the two groups are
   plainly the same family at two sizes, and the whole row is the link: the
   pill is a target to aim at, not the only live part. */
.work-rows {
  display: flex;
  flex-direction: column;
  gap: 12px;
  width: 100%;
  max-width: var(--column);
  margin: 0 auto;
}

[data-theme] .work-row {
  display: flex;
  align-items: center;
  gap: 20px;
  padding: 20px;
  border-radius: 20px;
  background-color: var(--page);
  color: var(--ink);
  text-decoration: none;
  /* Webflow gives every <a> `border: 1px #000; border-bottom-style: dashed`. */
  border-bottom-style: none;
  /* The card's own edge and cast, drawn as an inset ring for the same reason
     the phone card is: it keeps the 1px out of the layout. */
  box-shadow:
    inset 0 0 0 1px var(--line),
    var(--card-cast);
  transition:
    box-shadow 260ms cubic-bezier(0.23, 1, 0.32, 1),
    scale 160ms cubic-bezier(0.23, 1, 0.32, 1);
}

[data-theme] .work-row-logo {
  flex: 0 0 auto;
  width: 48px;
  height: 48px;
}

.work-row-text {
  display: flex;
  flex-direction: column;
  gap: 4px;
  /* A flex child's min-width is auto, so a long title would push the pill off
     the row instead of wrapping. */
  min-width: 0;
  flex: 1 1 auto;
}

/* Capitalised by the same rule that capitalises a card title — see the
   .text-head block above; this is the same kind of line at a smaller size. */
[data-theme] .work-row-title {
  color: var(--ink);
  font-family: Unbounded, sans-serif;
  font-size: 20px;
  line-height: 1.3;
  text-transform: capitalize;
  text-wrap: balance;
}

/* Display type is for the title; the line under it is reading text — the same
   split the card's stat makes between its figure and its label. */
[data-theme] .work-row-sub {
  color: var(--muted);
  font-family: Inter, sans-serif;
  font-size: 14px;
  line-height: 1.35;
  /* Max: "capitalized descriptions here pls." Same rule as every title on the
     site — written sentence case in the source, raised by CSS, so there is one
     place that decides the case. */
  text-transform: capitalize;
}

/* The pill says where the link goes. The mark is HugeIcons' LinkSquare02,
   outline at 1.5 like every other glyph here — an arrow leaving a square is
   the one shape that reads as "this opens somewhere else" without a word. */
[data-theme] .work-row-cta {
  display: inline-flex;
  flex: 0 0 auto;
  align-items: center;
  gap: 8px;
  height: 40px;
  padding: 0 16px;
  border-radius: 999px;
  color: var(--ink);
  font-family: Inter, sans-serif;
  font-size: 14px;
  font-weight: 500;
  white-space: nowrap;
  box-shadow: inset 0 0 0 1px var(--line);
  transition:
    background-color 260ms cubic-bezier(0.23, 1, 0.32, 1),
    color 260ms cubic-bezier(0.23, 1, 0.32, 1),
    box-shadow 260ms cubic-bezier(0.23, 1, 0.32, 1);
}

.work-row-icon {
  flex: 0 0 auto;
  /* 20, not 16. Effective stroke is the artwork's 1.5 scaled by box/24, so at
     16px this mark rendered at 1.0px against the 1.25px every other mark on the
     site carries — a quarter thinner, which read as faded beside its own 14px
     label. DESIGN.md's rule is to scale the box and never touch the stroke.
     20px puts it at exactly the dock's weight. */
  width: 20px;
  height: 20px;
}

@media (hover: hover) and (pointer: fine) {
  /* The same arriving beat the cards use, and the pill fills: the row answers
     as one object rather than the button lighting up on its own. */
  [data-theme] .work-row:hover {
    box-shadow:
      inset 0 0 0 1px color-mix(in srgb, var(--ink) 16%, transparent),
      0 2px 4px rgba(3, 17, 28, 0.04),
      0 18px 36px -24px rgba(3, 17, 28, 0.26);
    transition: box-shadow 420ms cubic-bezier(0.23, 1, 0.32, 1);
  }

  [data-theme] .work-row:hover .work-row-cta {
    background-color: var(--ink);
    color: var(--page);
    box-shadow: inset 0 0 0 1px var(--ink);
  }
}

.work-row:active {
  scale: 0.996;
}

.work-row:focus-visible {
  outline: 2px solid var(--accent-ink);
  outline-offset: 3px;
  border-radius: 20px;
}

@media (prefers-reduced-motion: reduce) {
  [data-theme] .work-row,
  [data-theme] .work-row-cta {
    transition: none;
  }

  .work-row:active {
    scale: 1;
  }
}

@media screen and (max-width: 767px) {
  [data-theme] .work-row {
    gap: 14px;
    padding: 16px;
    border-radius: 16px;
  }

  [data-theme] .work-row-logo {
    width: 40px;
    height: 40px;
  }

  [data-theme] .work-row-title {
    font-size: 16px;
  }

  [data-theme] .work-row-sub {
    font-size: 13px;
  }

  .work-row:focus-visible {
    border-radius: 16px;
  }
}

@media screen and (max-width: 479px) {
  /* 358px of row minus 32 of padding, 40 of logo and 14 of gap leaves 272 for
     the text and the pill. "Where stablecoins become capital" needs most of
     it, so the word comes out and the mark carries the meaning on its own —
     it is the part Max asked for. The row is still the link. */
  [data-theme] .work-row-cta {
    width: 40px;
    padding: 0;
    gap: 0;
    justify-content: center;
  }

  .work-row-cta-label {
    display: none;
  }
}


/* --- A film with a poster does not fade in --------------------------------
   Max: "when reloading there's like img apear for a sec and than the video,
   can we fix that?"

   The cover film opens at opacity 0 and fades in over 600ms once it is really
   playing, which is right when there is nothing better to show — but the still
   underneath is a different picture, so for that half second BOTH are on
   screen at once and you get a double exposure of a screenshot and a film.
   Measured off his recording: the profile page's `limitless-own.webp` ghosts
   straight through the film's title card.

   With a `poster` there is nothing to cross-fade FROM: the poster is the
   film's own first frame, the browser paints it in the video's own box with
   the same object-fit, and playing it just advances to frame 1. So the element
   is opaque from the start and the transition comes off. Keyed on the
   attribute rather than a class, so the two cannot drift apart — a film
   without a poster still gets the old fade, which is still the right thing
   for it. */
.cover-video[poster] {
  opacity: 1;
  transition: none;
}


/* --- A cover can be the project's own field, with the work laid on it ------
   Max, pointing at the Kyrrex hero: "let's edit a bit / keep as a bg same
   gradient motion as on preview card pls / than add this image in nice zoom."

   So a cover is no longer only a photograph. With `coverField` in the front
   matter the container becomes the same thing the work card's artwork half is
   — React Bits' Faulty Terminal on the project's own hue, canvas over
   gradient, keyed by the same `data-project` attribute so there is one place
   that decides what colour Kyrrex is. The gradient underneath is the same
   declaration the card uses, not a copy: `faulty-terminal.js` drops its canvas
   where WebGL2 is missing, and what is left has to be the panel we had.

   `coverArt` is then laid over it. The file is a cut-out — 52% of it is
   transparent — so it sits ON the field rather than in a white box, which is
   the whole reason this works as a hero and not as a screenshot. */
.cover-image-container[data-ft] {
  position: relative;
  /* The still underneath is the fallback and the thing header-contrast.js
     samples; the field is opaque, so it is what you see. */
  overflow: hidden;
}

.cover-art {
  position: relative;
  z-index: 1;
  display: block;
  width: 100%;
  height: 100%;
  /* The art is 3:2 and the cover is about 2.2:1, so `cover` trims the air off
     the top and bottom and leaves the screens filling the frame — which is
     what "nice zoom" means here. The scale is a knob rather than a baked crop
     so the framing can move without re-exporting the file. */
  object-fit: cover;
  object-position: 50% 50%;
  /* Max: "nice just bigger screens and lower pls." Scale and drop are separate
     knobs on purpose — `object-position` cannot do both, because once the art
     is scaled past the box there is nothing left for it to reposition. The
     drop is a share of the art's own height, so it holds at every width. */
  scale: var(--cover-art-zoom, 1.22);
  translate: 0 var(--cover-art-drop, 25%);
}

@media screen and (max-width: 767px) {
  .cover-art {
    scale: var(--cover-art-zoom-phone, 1.2);
    translate: 0 var(--cover-art-drop-phone, 18%);
  }
}


/* --- A shot is capped in height ------------------------------------------
   Max: "make sure all image sections have fixed heigh, like 720px max height
   or smth."

   A case-study shot used to take whatever height its aspect ratio asked for,
   so a tall mobile capture became a 1,400px block you scroll past rather than
   read, next to a wide desktop one 400px tall. The cap is on the picture, not
   the panel, so the seat keeps its padding and the shot centres inside it:
   `width: auto` with `max-width: 100%` lets a wide shot behave exactly as it
   did — it hits the width limit first and the cap never applies — while a tall
   one hits 720 and shrinks, which is the only case this is for. */
:root {
  --shot-max-h: 720px;
}

[data-theme] .image-container-works .cms-image,
[data-theme] .slider-slide img {
  width: auto;
  max-width: 100%;
  max-height: var(--shot-max-h);
  margin-inline: auto;
}

@media screen and (max-width: 767px) {
  :root {
    /* On a phone the measure is 358px, so 720 of height is already more than
       two screenfuls. */
    --shot-max-h: 520px;
  }
}


/* --- The Impact section leads with its figures -----------------------------
   Max, with a Circle case study as the reference: "impact section should look
   like this" — the heading, a short paragraph, then a row of big figures with
   a small caption under each.

   Two things differ from the reference on purpose. The figures are `--ink`,
   not the reference's purple: this site is black and white, and the two
   colours that survive both carry meaning (the Ukraine flag, the availability
   pill) rather than decorating. And the hierarchy is carried the way the work
   card's stat already carries it — Unbounded for the figure, Inter for the
   label, two faces and two inks — so a case study's figures and a card's
   figure are recognisably the same object at two sizes.

   A figure only goes here when the prose above it has already shown its
   working. Nothing is rounded up: +98% is 432 to 854, +78% is 578 to 1,029. */
.impact-stats {
  display: grid;
  grid-template-columns: repeat(3, minmax(0, 1fr));
  gap: 32px;
  width: 100%;
  max-width: var(--column);
  margin-inline: auto;
}

.impact-stat {
  display: flex;
  flex-direction: column;
  gap: 6px;
  min-width: 0;
}

[data-theme] .impact-stat-value {
  color: var(--ink);
  font-family: Unbounded, sans-serif;
  font-size: 44px;
  line-height: 1.04;
  letter-spacing: -0.02em;
  /* The figures sit in a row, so a changing digit width would shift the
     captions out of line with each other. */
  font-variant-numeric: tabular-nums;
}

[data-theme] .impact-stat-label {
  color: var(--muted);
  font-family: Inter, sans-serif;
  font-size: 15px;
  line-height: 1.35;
  text-wrap: pretty;
}

@media screen and (max-width: 767px) {
  .impact-stats {
    /* Three across at 358px gives each figure about 98px, which breaks
       "+78%" over two lines. Two columns hold it and the third wraps. */
    grid-template-columns: repeat(2, minmax(0, 1fr));
    gap: 24px 20px;
  }

  [data-theme] .impact-stat-value {
    font-size: 32px;
  }

  [data-theme] .impact-stat-label {
    font-size: 14px;
  }
}


/* --- A case study's sections are numbered --------------------------------
   Max: "make these titles bigger / have also 01, 02 in almost not visible
   color in front of title."

   The numbers come from a CSS counter rather than the copy, so they cannot
   drift out of order when a section is added, moved or cut — there is one
   place that decides, and it is the document order. `decimal-leading-zero`
   is what gives 01 rather than 1.

   The ink is deliberately far below the body minimum. It is allowed to be,
   for the same reason the 404's eyebrow is: the number carries no
   information the heading beside it does not already carry, so it is
   decoration, and decoration that competes with the word it labels has
   failed. The heading itself stays on --ink. */
[data-theme] .content-text h4 {
  position: relative;
  font-size: 36px;
  line-height: 1.12;
  letter-spacing: -0.01em;
  margin-top: 8px;
  margin-bottom: 20px;
}

[data-theme] .content-text h4::before {
  content: counter(case-section, decimal-leading-zero);
  counter-increment: case-section;
  margin-right: 0.5em;
  color: color-mix(in srgb, var(--ink) 16%, transparent);
  font-variant-numeric: tabular-nums;
}

/* The counter is reset on the page shell, because the sections are siblings
   several containers apart and there is no single wrapper around them. */
[data-theme] body {
  counter-reset: case-section;
}

@media screen and (max-width: 767px) {
  [data-theme] .content-text h4 {
    font-size: 28px;
    margin-bottom: 16px;
  }
}
