/* =====================================================================
   rebuild.css — the redesigned system.

   ONE SYSTEM, DERIVED FROM THE FROZEN SECTIONS
   --------------------------------------------
   Every token below is lifted from the four frozen sections rather than
   invented, so the rebuilt areas and the untouched ones are the same
   design rather than two designs sharing a page:

     ground / ink / rule   .expertise-section  (--ink / --cream / --line)
     edge + vertical pad   .expertise-section  (16px / 120px, 64px ≤700)
     header block          .expertise-top      (2fr 1fr, gap 48, mb 64)
     divider row           .expertise-item     (128px 2.4fr 1fr, gap 32)
     display type          .expertise-heading  + .work-title
     row label type        .expertise-item-name
     mid title type        .project-title
     lead type             .hero-supporting
     kicker type           .expertise-kicker
     micro label type      .project-category
     body type             .expertise-item-desc

   Case follows the frozen sections exactly: headings, row labels and
   micro labels are UPPERCASE; kickers, leads and body copy are
   lowercase (see .hero-supporting, which lowercases running prose).

   SCOPING RULE (non-negotiable): every selector is prefixed .rb- (or
   targets an element that only exists inside a .rb- block). No element
   selectors, no resets, and the existing --ink / --cream / --line
   custom properties are READ but never reassigned, because the frozen
   sections inherit through them.

   Loaded AFTER portfolio-experience.css / pages.css.
   ===================================================================== */

/* ---------------------------------------------------------------------
   Cedora — the typeface of the Swiftpay interface, not of the site.

   Declared here rather than in portfolio-experience.css because nothing
   outside the phone screen uses it: the site itself stays Neue Montreal.
   Same pattern as the Neue Montreal faces (local() first, then the .otf,
   font-display: swap).
   --------------------------------------------------------------------- */

@font-face {
  font-family: 'Cedora';
  src: local('Cedora Regular'), local('Cedora-RegularStd'),
       url('Fonts/Cedora-RegularStd.otf') format('opentype');
  font-weight: 400;
  font-style: normal;
  font-display: swap;
}

@font-face {
  font-family: 'Cedora';
  src: local('Cedora Regular Italic'), local('Cedora-RegularItalicStd'),
       url('Fonts/Cedora-RegularItalicStd.otf') format('opentype');
  font-weight: 400;
  font-style: italic;
  font-display: swap;
}

@font-face {
  font-family: 'Cedora';
  src: local('Cedora Bold'), local('Cedora-BoldStd'),
       url('Fonts/Cedora-BoldStd.otf') format('opentype');
  font-weight: 700;
  font-style: normal;
  font-display: swap;
}

@font-face {
  font-family: 'Cedora';
  src: local('Cedora Bold Italic'), local('Cedora-BoldItalicStd'),
       url('Fonts/Cedora-BoldItalicStd.otf') format('opentype');
  font-weight: 700;
  font-style: italic;
  font-display: swap;
}

/* ---------------------------------------------------------------------
   Montserrat — the typeface of the Kursbit interface, on the same terms
   as Cedora above: it exists for one screen, and the site itself stays
   Neue Montreal.

   Two variable files rather than the eight static cuts the design uses
   (Light 300 through ExtraBold 800, plus italics). The latin subset of
   the variable face is 38KB for the whole 100-900 axis — less than three
   static weights would cost — and 'font-weight: 100 900' on the
   descriptor is what lets the browser pick any of them. No local()
   first-guess here, unlike Cedora: a locally installed static Montserrat
   would win the lookup and then serve only the one weight it happens to
   be, silently flattening the design's weight range.
   --------------------------------------------------------------------- */

@font-face {
  font-family: 'Montserrat';
  src: url('Fonts/Montserrat-Variable.woff2') format('woff2');
  font-weight: 100 900;
  font-style: normal;
  font-display: swap;
}

@font-face {
  font-family: 'Montserrat';
  src: url('Fonts/Montserrat-Italic-Variable.woff2') format('woff2');
  font-weight: 100 900;
  font-style: italic;
  font-display: swap;
}

:root {
  /* --- Palette: one ground, one ink, one rule. ---------------------- */
  --rb-ground: var(--ink);
  --rb-ink: var(--cream);
  --rb-rule: var(--line);

  /* --- Viewport height unit -----------------------------------------
     Everything below used to scale on width alone, so a 1280×720 laptop
     got the same tall compositions as a 2560 monitor and sections ran
     past the fold. The vertical rhythm, the display sizes and the media
     now clamp against the height as well, and whichever is tighter
     wins. 1svh (the small viewport: URL bar showing) where supported, so
     a phone never sizes a frame against space its toolbar is covering;
     plain vh is the fallback, set first and overridden below. */
  --rb-vh: 1vh;

  /* --- Spacing, from .expertise-section / .expertise-top / -item ---- */
  --rb-edge: 16px;
  --rb-section-y: clamp(64px, calc(var(--rb-vh) * 11), 120px);
  --rb-head-gap: 48px;
  --rb-head-mb: clamp(32px, calc(var(--rb-vh) * 6), 64px);
  --rb-row-gap: 32px;
  --rb-row-y: 20px;
  --rb-row-lead: 128px;
  --rb-stack: clamp(16px, calc(var(--rb-vh) * 2.6), 24px);

  /* One screen, as a length. Frames are sized to it. */
  --rb-screen: calc(var(--rb-vh) * 100);

  /* --- Type ranges -------------------------------------------------- */
  --rb-display: clamp(36px, min(6vw, calc(var(--rb-vh) * 11)), 100px);
  --rb-rowname: clamp(70px, 5.2vw, 82px);
  --rb-title: clamp(18px, 1.75vw, 29px);
  --rb-lead: clamp(20px, min(1.75vw + 3px, calc(var(--rb-vh) * 3.2)), 25px);
  --rb-kicker: 18px;
  /* 11, not the frozen .project-category's 10: these are uppercase,
     tracked labels carrying real information (year, role, captions), and
     at 10px they fell under comfortable reading size on every screen. */
  --rb-micro: 11px;
  --rb-body: clamp(14px, 1.05vw, 16px);

  --rb-font: 'Neue Montreal', Arial, sans-serif;
}

/* The frozen section's own mobile step-down, applied to the whole
   system so nothing reflows on a different schedule than the sections
   it sits between. */
@supports (height: 1svh) {
  :root { --rb-vh: 1svh; }
}

@media (max-width: 700px) {
  :root {
    --rb-section-y: clamp(48px, calc(var(--rb-vh) * 8), 64px);
    --rb-head-gap: 16px;
    --rb-head-mb: 40px;
    --rb-row-gap: 10px;
    --rb-row-lead: 0px;
    --rb-rowname: clamp(34px, 12vw, 48px);
    --rb-stack: 18px;
  }
}

/* ---------------------------------------------------------------------
   Structure
   --------------------------------------------------------------------- */

.rb-section {
  position: relative;
  padding: var(--rb-section-y) var(--rb-edge);
  /* Transparent, not --rb-ground: .rb-bg paints the ground for the whole
     document now (see the background field below), and the rebuilt
     sections let it through. The frozen sections keep painting their own
     flat --ink, which is what gives the page its rhythm — the field
     breathes between them rather than washing over them. */
  background: transparent;
  color: var(--rb-ink);
  font-family: var(--rb-font);
  font-weight: 400;
}

/* A frame: a section that is one composition, sized to exactly one
   screen and centred in it, so it is never seen cut in half by the fold.
   min-height rather than height — the type and media inside are what
   is height-aware (see --rb-vh), and if a pathological window (a 500px-
   tall landscape phone) still cannot hold it, it grows rather than
   clipping. Only for compositions: lists and galleries stay long and
   frame each item instead. */
.rb-frame {
  min-height: var(--rb-screen);
  display: flex;
  flex-direction: column;
  justify-content: center;
}

.rb-explore-more { display: flex; justify-content: center; padding-top: 0; }

/* Section header — .expertise-top: heading left at 2fr, kicker column
   right at 1fr, aligned to the top and to the right edge. */
.rb-head {
  display: grid;
  grid-template-columns: 2fr 1fr;
  align-items: start;
  gap: var(--rb-head-gap);
  margin-bottom: var(--rb-head-mb);
}

/* Wide variant: the heading gets all the room it needs and the kicker
   only shrink-wraps. Used where the heading runs at display size with
   authored <br> lines that would otherwise wrap inside a 2fr track (the
   Approach section). */
.rb-head--wide { grid-template-columns: minmax(0, 1fr) auto; }

.rb-head-side {
  display: flex;
  flex-direction: column;
  align-items: flex-end;
  gap: 12px;
  text-align: right;
}

@media (max-width: 700px) {
  .rb-head,
  .rb-head--wide { grid-template-columns: 1fr; }
  .rb-head-side { align-items: flex-start; text-align: left; }
}

/* Divider rows — .expertise-item. Used by Point of view's principles,
   Range's disciplines, the About page's CV, and the case-study meta, so
   those all share one rhythm with the frozen capability list. */
.rb-rows { margin: 0; padding: 0; list-style: none; }

.rb-row {
  display: grid;
  grid-template-columns: var(--rb-row-lead) 2.4fr 1fr;
  gap: var(--rb-row-gap);
  align-items: center;
  padding: var(--rb-row-y) 0;
  border-top: 1px solid var(--rb-rule);
}

.rb-rows > .rb-row:last-child { border-bottom: 1px solid var(--rb-rule); }

@media (max-width: 700px) {
  .rb-row { grid-template-columns: 1fr; align-items: start; }
}

.rb-stack { display: flex; flex-direction: column; gap: var(--rb-stack); }

/* ---------------------------------------------------------------------
   Type — every value below is copied from a frozen rule.
   --------------------------------------------------------------------- */

/* .expertise-heading / .work-title */
.rb-display,
.rb-cta-heading,
.rb-page-hero-heading,
.rb-case-title,
.rb-case-next-title {
  max-width: 20ch;
  margin: 0;
  color: var(--rb-ink);
  text-transform: uppercase;
  font: 500 var(--rb-display) / 0.92 var(--rb-font);
  letter-spacing: -0.045em;
}

.rb-case-title,
.rb-case-next-title,
.rb-cta-heading { max-width: 16ch; }

/* .expertise-item-name */
.rb-principle-name {
  color: var(--rb-ink);
  text-transform: uppercase;
  font: 500 var(--rb-rowname) / 0.85 var(--rb-font);
  letter-spacing: -0.03em;
}

@media (max-width: 700px) {
  .rb-principle-name { line-height: 1; }
}

/* .project-title — the dense-list size, for CV orgs and case metadata */
.rb-title,
.rb-cv-org,
.rb-case-meta-val,
.rb-footer-name {
  margin: 0;
  color: var(--rb-ink);
  text-transform: uppercase;
  font: 500 var(--rb-title) / 0.95 var(--rb-font);
  letter-spacing: -0.045em;
}

/* .hero-supporting — the site's running-prose lead */
.rb-lead,
.rb-case-statement,
.rb-approach-copy p {
  max-width: 34ch;
  margin: 0;
  color: var(--rb-ink);
  text-transform: lowercase;
  font: 500 var(--rb-lead) / 1.3 var(--rb-font);
  letter-spacing: -0.01em;
}

.rb-case-statement { max-width: 44ch; }
.rb-approach-copy p { max-width: 46ch; }

/* .expertise-kicker */
.rb-eyebrow,
.rb-footer-label,
.rb-prose-label {
  margin: 0;
  color: var(--rb-ink);
  text-transform: lowercase;
  font: 400 var(--rb-kicker) / 1.25 var(--rb-font);
  letter-spacing: 0.09em;
}

/* .project-category */
.rb-micro,
.rb-case-caption,
.rb-case-meta-key,
.rb-cv-period,
.rb-principle-num,
.rb-footer-place,
.rb-footer-bottom,
.rb-field label {
  color: var(--rb-ink);
  text-transform: uppercase;
  font: 500 var(--rb-micro) / 1.25 var(--rb-font);
  letter-spacing: 0.09em;
}

/* .expertise-item-desc */
.rb-body,
.rb-cv-detail,
.rb-footer-desc,
.rb-columns li,
.rb-footer-list a {
  max-width: 46ch;
  margin: 0;
  color: var(--rb-ink);
  text-transform: lowercase;
  font: 400 var(--rb-body) / 1.55 var(--rb-font);
  letter-spacing: -0.01em;
}

/* Names, addresses and URLs keep their own casing — lowercasing an
   organisation or a person is a factual change, not a stylistic one. */
.rb-cv-org,
.rb-footer-name,
.rb-nocase,
.rb-footer-list a[href^="mailto:"],
.rb-link[href^="mailto:"] .rb-link-roll span { text-transform: none; }

/* ---------------------------------------------------------------------
   Reveal primitives
   --------------------------------------------------------------------- */

.rb-line-mask {
  display: block;
  overflow: hidden;
  /* overflow:hidden clips to the line box, which sits above the
     descender at these tight line-heights. Extra room at the bottom,
     the same amount taken back as margin, so height is unchanged. */
  padding-bottom: 0.16em;
  margin-bottom: -0.16em;
}
/* No will-change here on purpose. These animate exactly once (the reveal
   tween in rebuild.js uses `once: true`) and there are 22 of them on the
   home page, so a static hint meant 22 compositor layers held for the whole
   session to serve one 0.95s tween each. GSAP promotes the element for the
   duration of the tween and drops it afterwards, which is what we want. */
.rb-line-inner { display: block; }

@media (prefers-reduced-motion: no-preference) {
  .rb-reveal { opacity: 0; }
  .rb-media-reveal { clip-path: inset(0 0 100% 0); }
}

.rb-media-reveal { overflow: hidden; }
.rb-media-reveal > img,
.rb-media-reveal > video,
.rb-media-reveal > svg {
  display: block;
  width: 100%;
  height: 100%;
  object-fit: cover;
}

/* ---------------------------------------------------------------------
   Links and buttons
   --------------------------------------------------------------------- */

.rb-section a,
.rb-page-hero a,
.rb-footer a,
.rb-case a,
.rb-form-success a { text-decoration: none; }

.rb-link {
  position: relative;
  display: inline-flex;
  align-items: center;
  gap: 0.6em;
  padding-bottom: 0.35em;
  border: 0;
  background: none;
  color: var(--rb-ink);
  cursor: pointer;
  text-transform: lowercase;
  font: 400 var(--rb-kicker) / 1.25 var(--rb-font);
  letter-spacing: 0.09em;
  font-family: var(--rb-font);
}

.rb-link-roll {
  position: relative;
  display: block;
  overflow: hidden;
  line-height: 1.25;
}

.rb-link-roll span {
  display: block;
  transition: transform 0.42s cubic-bezier(0.76, 0, 0.24, 1);
}

/* Promote only while the pointer is actually on the link. This is a CSS
   transition rather than a GSAP tween, so nothing else can add and drop the
   hint for us — but hovering the parent is what starts the child's
   transition, so the hint still lands one frame ahead of the movement. */
.rb-link-roll:hover span,
.rb-link-roll:focus-visible span {
  will-change: transform;
}

.rb-link-roll span + span {
  position: absolute;
  inset: 0;
  transform: translateY(100%);
}

@media (hover: hover) and (pointer: fine) {
  .rb-link:hover .rb-link-roll span:first-child,
  .rb-link:focus-visible .rb-link-roll span:first-child { transform: translateY(-100%); }
  .rb-link:hover .rb-link-roll span + span,
  .rb-link:focus-visible .rb-link-roll span + span { transform: translateY(0); }
}

/* Underline curtain wipe: the resting line exits right while its
   replacement enters from the left. */
.rb-link-line,
.rb-link-line-2 {
  position: absolute;
  left: 0;
  right: 0;
  bottom: 0;
  height: 1px;
  background: currentColor;
  transition: clip-path 0.45s cubic-bezier(0.76, 0, 0.24, 1);
}

.rb-link-line { clip-path: inset(0 0 0 0); }
.rb-link-line-2 { clip-path: inset(0 100% 0 0); transition-delay: 0.12s; }

@media (hover: hover) and (pointer: fine) {
  .rb-link:hover .rb-link-line,
  .rb-link:focus-visible .rb-link-line { clip-path: inset(0 0 0 100%); }
  .rb-link:hover .rb-link-line-2,
  .rb-link:focus-visible .rb-link-line-2 { clip-path: inset(0 0 0 0); }
}

.rb-link:focus-visible { outline: 1px solid currentColor; outline-offset: 6px; }

/* Nav links get the curtain wipe with ZERO geometry change — measured
   against the pre-rebuild build, every box in the nav is identical. No
   padding is added (the lines are positioned outside the box) and the
   email CTA keeps its 5px padding and 1px border, made transparent so
   the animated lines can play that role in the same place. */
.site-nav__links a.rb-navlink,
.site-nav__email.rb-navlink { position: relative; }

.site-nav__email.rb-navlink { border-bottom-color: transparent; }

.rb-navlink .rb-link-line,
.rb-navlink .rb-link-line-2 { background: currentColor; opacity: 0.55; }

.site-nav__email.rb-navlink .rb-link-line { bottom: -1px; opacity: 0.35; }
.site-nav__email.rb-navlink .rb-link-line-2 { bottom: -1px; }

.site-nav__links a.rb-navlink .rb-link-line,
.site-nav__links a.rb-navlink .rb-link-line-2 { bottom: -5px; }
.site-nav__links a.rb-navlink .rb-link-line { clip-path: inset(0 100% 0 0); }

@media (hover: hover) and (pointer: fine) {
  .site-nav__links a.rb-navlink:hover .rb-link-line { clip-path: inset(0 0 0 0); }
  .site-nav__links a.rb-navlink:hover .rb-link-line-2 { clip-path: inset(0 0 0 100%); }
}

.site-nav__links a.rb-navlink[aria-current] .rb-link-line { clip-path: inset(0 0 0 0); opacity: 1; }

/* ---------------------------------------------------------------------
   Custom cursor — desktop only, and only inside [data-rb-cursor] scopes,
   which exist exclusively on new markup.
   --------------------------------------------------------------------- */

.rb-cursor {
  position: fixed;
  top: 0;
  left: 0;
  z-index: 9800;
  display: grid;
  place-items: center;
  min-width: 84px;
  height: 84px;
  padding: 0 14px;
  border-radius: 50%;
  background: var(--rb-ink);
  color: var(--rb-ground);
  text-transform: uppercase;
  font: 500 var(--rb-micro) / 1.25 var(--rb-font);
  letter-spacing: 0.09em;
  pointer-events: none;
  opacity: 0;
  transform: translate(-50%, -50%) scale(0.4);
  will-change: transform;
}

@media (hover: none), (pointer: coarse) {
  .rb-cursor { display: none !important; }
}

[data-rb-cursor],
[data-rb-cursor] * { cursor: none; }

/* ---------------------------------------------------------------------
   Point of view
   --------------------------------------------------------------------- */

.rb-principle-num { align-self: center; }

/* Work fragment that appears alongside the hovered discipline. */
.rb-range-figure {
  position: fixed;
  /* Anchor to the viewport origin. Without these, `top`/`left` resolve to the
     static position — where the figure would have sat in flow, thousands of
     px down the document — and the GSAP x/y translate (which is in raw
     clientX/clientY terms) is applied on top of that, parking the figure
     permanently off-screen. Measured before this line existed: the Range
     figure sat at y=12142 and the Expertise one at y=6537 in a 900px
     viewport. */
  top: 0;
  left: 0;
  z-index: 40;
  width: clamp(220px, 20vw, 340px);
  aspect-ratio: 4 / 3;
  overflow: hidden;
  pointer-events: none;
  opacity: 0;
  transform: translate(-50%, -50%);
  will-change: transform, opacity;
}

.rb-range-figure img { display: block; width: 100%; height: 100%; object-fit: cover; }

.rb-range-figure-label {
  position: absolute;
  top: 50%;
  left: 50%;
  transform: translate(-50%, -50%);
  width: 23%;
  aspect-ratio: 1 / 1;
  border-radius: 50%;
  display: grid;
  place-items: center;
  background: color-mix(in srgb, var(--rb-ground) 85%, transparent);
  color: var(--rb-ink);
  text-transform: uppercase;
  font: 500 clamp(12px, 0.9vw, 14px) / 1 var(--rb-font);
  letter-spacing: 0.14em;
  /* Tracking adds a trailing space after the last letter, which pushes the
     word visually left of centre. Indenting by the same amount puts it back. */
  text-indent: 0.14em;
}

/* Hidden only where the preview actually runs — the same conditions as the
   display rule below, so a pointer that gets no preview keeps its cursor. */
@media (hover: hover) and (pointer: fine) and (min-width: 992px) {
  [data-rb-range="expertise"] { cursor: none; }
}

@media (hover: none), (pointer: coarse), (max-width: 991px) {
  .rb-range-figure { display: none; }
}

/* ---------------------------------------------------------------------
   Approach — an even media / copy split, centred rather than full-bleed,
   since here the mark is a supporting detail rather than the section's
   own subject.
   --------------------------------------------------------------------- */

.rb-approach-grid {
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: var(--rb-head-gap);
  align-items: center;
  justify-items: center;
  /* Centred, constrained block rather than the section's usual full-bleed
     grid — this section reads as a centred editorial pair on request. */
  max-width: 1120px;
  margin-inline: auto;
}

@media (max-width: 700px) {
  .rb-approach-grid { grid-template-columns: 1fr; max-width: 420px; }
}

/* Deliberately smaller than the other .rb-display headings on this page
   (.rb-cta-heading etc., capped at 100px): these two match the frozen
   .expertise-heading treatment instead — same 85px cap, 0.9 line-height,
   -0.05em tracking — while still taking the base .rb-display class for
   its shared color/uppercase/margin reset. Its <br>-delimited lines are
   already authored, so nothing here needs the 20ch wrap cap either. */
.rb-approach-heading,
.rb-about-heading {
  max-width: none;
  font-size: clamp(36px, min(6vw, calc(var(--rb-vh) * 9.5)), 85px);
  line-height: 0.9;
  letter-spacing: -0.05em;
  font-weight: 500;
}

.rb-approach-media { margin: 0; aspect-ratio: 1 / 1; width: 100%; max-width: 320px; }
.rb-approach-media img { display: block; width: 100%; height: 100%; object-fit: cover; }

/* ---------------------------------------------------------------------
   Approach stage — the monogram's construction behind, the Swiftpay
   build standing in front of it.

   Two layers on one square rather than two tiles side by side: the mark
   is drawn at the top-left at its own size, and the phone stands at the
   bottom-right and overlaps its lower corner, so identity and product
   read as one object built from the same system — which is the sentence
   the heading beside it is making.

   The phone is the same .rb-dev / .rb-dev-screen / .rb-dev-frame trio
   the devices section uses. Only two things are set here: --rb-u (the
   unit the hardware and the interface inside it are both sized from) and
   a resting three-quarter tilt. Nothing animates it — the entrance is
   .rb-reveal on the wrapper, which moves y/opacity only, so the tilt on
   the device inside is never fought over.
   --------------------------------------------------------------------- */

.rb-approach-stage {
  position: relative;
  width: 100%;
  max-width: 470px;
  margin-inline: auto;
  /* Holds the phone, its label band and the shadow it casts below both.
     The height term is what keeps the whole Approach frame on one
     screen on a short laptop — the heading above takes the rest. */
  min-height: min(clamp(400px, 33vw, 520px), calc(var(--rb-vh) * 46));
  perspective: 1700px;
}

/* Pinned to the top-left of the stage at a size that leaves the phone a
   clear diagonal: any larger and the two silhouettes merge. */
.rb-approach-stage .rb-approach-media { max-width: min(clamp(210px, 23vw, 330px), calc(var(--rb-vh) * 34)); }

.rb-approach-device {
  position: absolute;
  /* Inset from the stage's right edge, not flush to it: the overlap with
     the mark's lower-right corner is what makes the two read as one
     object, and flush right left them sitting side by side. */
  right: 6%;
  bottom: calc(40 * var(--rb-u));
  z-index: 2;

  /* Its own unit, unrelated to the devices stage's: there the machine is
     sized to fill the viewport between the blurred bands, here the phone
     is sized to sit inside a 470px column. */
  /* The height term: 441 units of phone plus its 40-unit label band
     have to fit the stage, which is itself capped at 46 screen-percent. */
  --rb-u: min(clamp(0.58px, 0.055vw + 0.22px, 0.98px), calc(var(--rb-vh) * 0.092));
}

/* Three-quarter rest pose. Turned the same way the devices section turns
   its machine on the way in, so the two product shots share a viewpoint. */
.rb-approach-device .rb-dev {
  transform: rotateY(-16deg) rotateX(5deg) rotateZ(-2deg);
  transform-origin: 62% 58%;
}

/* The label is gated on the devices stage's .is-settled everywhere else.
   Here there is no stage and no entrance to wait for. */
.rb-approach-device .rb-dev-label { opacity: 1; }

/* ---------------------------------------------------------------------
   Approach media — the monogram drawn with a pen tool.

   The static markup is the finished mark, so no-JS and reduced-motion
   both land on a correct logo and nothing here animates on its own.
   rebuild.js's initPenBuild() adds .is-building, generates the
   construction layer (segments, anchors, Bezier handles, rubber band,
   pen nib) from the paths' real coordinates and drives the whole thing
   on one GSAP timeline — see that function for the sequence.
   --------------------------------------------------------------------- */
.rb-ap-anim { background: transparent; }
.rb-ap-shape { fill: var(--rb-ink); }

/* Construction chrome (canvas grid) only exists while JS is driving. */
.rb-ap-chrome { opacity: 0; }

/* Handed to GSAP the moment the build starts: the finished fill drops
   out so the pen can put it back. */
.rb-ap-anim.is-building .rb-ap-shape { fill-opacity: 0; }

/* No transform-box / transform-origin override on the generated anchor
   squares, deliberately. GSAP already resolves SVG transform origins
   itself, writing a user-space matrix that translates to the element's
   centre, scales, and translates back — that maths assumes the default
   transform-box: view-box. Forcing fill-box re-bases the same matrix
   against each element's own bounding box, which displaces every square
   by roughly its own coordinate offset mid-tween: they visibly snap in
   from the upper-left as they pop. Let GSAP own it. */

.rb-approach-copy { display: flex; flex-direction: column; align-items: flex-start; gap: var(--rb-stack); max-width: 46ch; }

/* Matches the .work-index treatment on the home page's Selected work
   header — same dimmed grey, size and tracking (13px vs the 10px --rb-micro
   default). A solid colour, not opacity: this element also carries
   .rb-reveal, whose scroll-in tween lands on an inline opacity:1. */
.rb-approach-kicker {
  color: rgba(255, 253, 214, 0.42);
  font-size: 13px;
}

/* ---------------------------------------------------------------------
   About preview — mirrors the frozen .work-intro header: an oversized
   statement line on the left, a right-aligned index label + down arrow
   stacked in the narrow column. Below it, a tall image on the left and
   the heading + copy on the right.
   --------------------------------------------------------------------- */

/* Header row — the statement takes all the room it needs, the label /
   arrow column only shrink-wraps (same reasoning as .rb-head--wide,
   since the statement runs at display size with an authored <br>). */
.rb-about-head {
  display: grid;
  grid-template-columns: minmax(0, 1fr) auto;
  align-items: start;
  gap: var(--rb-head-gap);
  margin-bottom: var(--rb-head-mb);
}

/* Oversized statement line. The line break is authored in the markup
   (<br>), which the line splitter treats as a hard break and keeps the
   word spacing intact. */
.rb-about-eyebrow {
  max-width: none;
  margin: 0;
  color: var(--rb-ink);
  text-transform: uppercase;
  font: 500 clamp(40px, min(13vw, calc(var(--rb-vh) * 16)), 175px) / 0.92 var(--rb-font);
  letter-spacing: -0.045em;
}

/* Index label pinned top-right, arrow dropped to the bottom of the
   column so it sits level with the last line of the statement —
   identical mechanism to .work-kicker-group / .work-scroll-arrow. */
.rb-about-kicker-group {
  align-self: stretch;
  justify-self: end;
  display: flex;
  flex-direction: column;
  align-items: flex-end;
  text-align: right;
}

.rb-about-index {
  margin: 0;
  color: rgba(255, 253, 214, 0.42);
  text-transform: uppercase;
  font: 500 13px / 1.25 var(--rb-font);
  letter-spacing: 0.09em;
  white-space: nowrap;
}

.rb-about-logo {
  display: block;
  width: clamp(84px, 8vw, 148px);
  height: auto;
  margin: auto 0 0 auto;
  color: var(--rb-ink);
}

.rb-about-grid {
  display: grid;
  grid-template-columns: minmax(0, 0.72fr) minmax(0, 1.28fr);
  gap: clamp(40px, 6vw, 112px);
  /* Right column stretches to the image's height so the heading can pin
     to the top and the copy can sit against the lower half of the
     frame — the distribution the reference relies on. */
  align-items: stretch;
}

.rb-about-media {
  position: relative;
  margin: 0;
  width: 100%;
  /* No aspect-ratio on desktop: the photo takes whatever height the copy
     beside it needs and crops to it, rather than the copy being stretched
     to a fixed 3:4 portrait. That portrait was 600px tall at 1280 wide,
     which is what pushed this section to 1.8 screens. The floor keeps it
     a photograph rather than a strip when the copy is short. */
  min-height: calc(var(--rb-vh) * 34);
  overflow: hidden;
  background: #111;
}

/* Absolute so the image's own 1600px intrinsic height never contributes
   to the grid row — the row is sized by the copy, the photo fills it. */
.rb-about-media img {
  position: absolute;
  inset: 0;
  display: block;
  width: 100%;
  height: 100%;
  object-fit: cover;
  object-position: 50% 35%;
  filter: grayscale(1) contrast(1.04);
}

/* The second headline, one step down. It used to run at the same display
   size as the statement above it, so the section opened with two
   headlines shouting over each other; now the statement is the headline
   and this is the opening line of the copy. */
.rb-about .rb-about-heading {
  font-size: clamp(28px, min(3.4vw, calc(var(--rb-vh) * 6)), 56px);
  line-height: 0.95;
  letter-spacing: -0.04em;
}

.rb-about-main {
  display: flex;
  flex-direction: column;
  align-items: flex-start;
  justify-content: space-between;
  gap: var(--rb-head-mb);
}

.rb-about-copy {
  display: flex;
  flex-direction: column;
  align-items: flex-start;
  gap: var(--rb-stack);
}

/* Enlarged body copy, scaled to the frame: 22px on a tall screen, ~18px
   on a 720px-tall laptop, never below 16. */
.rb-about-p {
  max-width: 34ch;
  margin: 0;
  color: var(--rb-ink);
  text-transform: lowercase;
  font: 400 clamp(16px, min(1.4vw, calc(var(--rb-vh) * 2.5)), 22px) / 1.4 var(--rb-font);
  letter-spacing: -0.01em;
}

/* The link reads as a separate action, not another paragraph. */
.rb-about-copy .rb-link { margin-top: var(--rb-stack); }

/* Wide screens: the two paragraphs side by side, like the two columns of
   a spread, which halves the copy's height — the difference between the
   frame fitting a laptop screen and not. */
@media (min-width: 901px) {
  .rb-about-copy {
    display: grid;
    grid-template-columns: repeat(2, minmax(0, 1fr));
    column-gap: clamp(24px, 2.6vw, 48px);
    row-gap: 0;
    align-items: start;
  }
  .rb-about-copy .rb-link { grid-column: 1 / -1; justify-self: start; }
}

@media (max-width: 900px) {
  .rb-about-head { grid-template-columns: 1fr; gap: clamp(20px, 4vw, 32px); }
  .rb-about-kicker-group {
    justify-self: start;
    align-items: flex-start;
    text-align: left;
  }
  .rb-about-logo { margin: clamp(16px, 5vw, 28px) 0 0 0; }

  .rb-about-grid { grid-template-columns: 1fr; gap: clamp(32px, 6vw, 56px); }
  .rb-about-media { max-width: 440px; aspect-ratio: 4 / 3; min-height: 0; }
  /* Single column now: no image to distribute against, so fall back to a
     normal stacked rhythm. */
  .rb-about-main { justify-content: flex-start; gap: var(--rb-head-mb); }
}

@media (max-width: 700px) {
  .rb-about-p { font-size: 21px; }
}

/* ---------------------------------------------------------------------
   CTA
   --------------------------------------------------------------------- */

.rb-cta-foot {
  display: grid;
  grid-template-columns: 2fr 1fr;
  gap: var(--rb-head-gap);
  align-items: end;
  margin-top: var(--rb-head-mb);
}

@media (max-width: 700px) {
  .rb-cta-foot { grid-template-columns: 1fr; }
}

/* ---------------------------------------------------------------------
   Background field.

   One fixed layer, injected by rebuild.js, painting the ground for the
   whole document: a deep tonal wash plus three very wide radial fields
   that drift against each other. The rebuilt sections and the footer are
   transparent so it reads through them; the four frozen home-page
   sections keep painting their own flat --ink, so the field breathes
   between them rather than washing over them.

   z-index: -1 puts it under every in-flow element but still ABOVE the
   canvas background (the root background is painted below the stacking
   context entirely), which is what lets it show at all.

   No blur filter anywhere: radial-gradients are already continuous, and
   a blurred full-viewport layer is a full-screen repaint per frame on
   exactly the machines that can least afford one. The fields move on
   transform only, so each is one composited layer.
   --------------------------------------------------------------------- */

.rb-bg {
  position: fixed;
  inset: 0;
  z-index: -1;
  overflow: hidden;
  pointer-events: none;
  background:
    radial-gradient(125% 85% at 50% -12%, #18181c 0%, var(--rb-ground) 58%),
    var(--rb-ground);
}

/* Corner falloff. The fields are wide and soft, so without this the
   whole frame lifts evenly and reads as a lighter flat grey rather than
   as light in a room. Sits above the fields, below every element. */
.rb-bg::after {
  content: '';
  position: absolute;
  inset: 0;
  background: radial-gradient(118% 92% at 50% 42%, rgba(0, 0, 0, 0) 42%, rgba(0, 0, 0, 0.6) 100%);
}

.rb-bg-field {
  position: absolute;
  top: 0;
  left: 0;
  width: 78vmax;
  height: 78vmax;
  border-radius: 50%;
  will-change: transform;
}

/* Warm cream — the site's own ink colour used as light rather than type. */
.rb-bg-field--one {
  background: radial-gradient(closest-side, rgba(255, 253, 214, 0.072), rgba(255, 253, 214, 0) 72%);
  animation: rb-drift-one 46s ease-in-out infinite alternate;
}

/* Copper — the only hue on the site that is neither cream nor ink, kept
   far enough down in alpha that it registers as warmth, not as colour. */
.rb-bg-field--two {
  background: radial-gradient(closest-side, rgba(214, 126, 58, 0.055), rgba(214, 126, 58, 0) 70%);
  animation: rb-drift-two 61s ease-in-out infinite alternate;
}

/* A cool counter-tone, so the warm two have something to be warm against. */
.rb-bg-field--three {
  background: radial-gradient(closest-side, rgba(96, 124, 190, 0.05), rgba(96, 124, 190, 0) 70%);
  animation: rb-drift-three 53s ease-in-out infinite alternate;
}

@keyframes rb-drift-one {
  from { transform: translate3d(-22vw, -18vh, 0) scale(1); }
  to   { transform: translate3d(18vw, 26vh, 0) scale(1.18); }
}

@keyframes rb-drift-two {
  from { transform: translate3d(46vw, 34vh, 0) scale(1.1); }
  to   { transform: translate3d(14vw, -16vh, 0) scale(0.92); }
}

@keyframes rb-drift-three {
  from { transform: translate3d(28vw, 62vh, 0) scale(0.96); }
  to   { transform: translate3d(-8vw, 14vh, 0) scale(1.22); }
}

@media (prefers-reduced-motion: reduce) {
  .rb-bg-field { animation: none; }
  /* Without the drift the three would stack on the same corner, so each
     is parked where its own keyframes start. */
  .rb-bg-field--one { transform: translate3d(-22vw, -18vh, 0); }
  .rb-bg-field--two { transform: translate3d(46vw, 34vh, 0); }
  .rb-bg-field--three { transform: translate3d(28vw, 62vh, 0); }
}

/* ---------------------------------------------------------------------
   Footer — the last full frame of every page rather than a sign-off strip.

   Four columns of detail over a wordmark that runs the full bleed, with
   the same rule above it that separates every other row on the site. The
   wordmark is a link home, sits at low contrast so it reads as ground
   rather than as a heading competing with the page above it, and comes up
   on hover.
   --------------------------------------------------------------------- */

.rb-footer {
  position: relative;
  padding: var(--rb-section-y) var(--rb-edge) var(--rb-row-y);
  background: transparent;
  color: var(--rb-ink);
  font-family: var(--rb-font);
  border-top: 1px solid var(--rb-rule);
  will-change: transform;
  /* The wordmark below sizes itself against this box's width. */
  container-type: inline-size;
}

.rb-footer-top {
  display: grid;
  /* The contact column carries a display-size link and a sentence; the
     other three are a word and a line each, so they take only what they
     need. */
  grid-template-columns: 2.4fr 1fr 1fr 1.1fr;
  gap: var(--rb-head-gap);
  align-items: start;
}

@media (max-width: 1000px) {
  .rb-footer-top { grid-template-columns: 1fr 1fr; row-gap: var(--rb-head-mb); }
}

/* Phones: the contact block takes the full row and the three short
   columns pair up beneath it. Stacked single-file they made the footer
   1.26 screens tall on a 375×812 phone; paired, it is one frame. */
@media (max-width: 700px) {
  .rb-footer-top { grid-template-columns: 1fr 1fr; gap: 36px 20px; }
  .rb-footer-lead { grid-column: 1 / -1; }
}

.rb-footer-lead { max-width: 42ch; }
.rb-footer-name { margin-bottom: 12px; }
.rb-footer-place { display: block; margin-top: 10px; }
.rb-footer-label { margin-bottom: 20px; }

/* The email, at display size — it is the one action this footer is
   actually asking for, so it is the one thing set larger than a label. */
.rb-footer-mail {
  display: inline-block;
  margin-bottom: var(--rb-stack);
  font-size: clamp(24px, 2.6vw, 44px);
  font-weight: 500;
  line-height: 1.05;
  letter-spacing: -0.035em;
}

.rb-footer-desc { max-width: 38ch; }

.rb-footer-list {
  list-style: none;
  margin: 0;
  padding: 0;
  display: flex;
  flex-direction: column;
  gap: 8px;
}

.rb-footer-list a { transition: opacity 0.28s ease; }

@media (hover: hover) and (pointer: fine) {
  /* 0.52 not 0.45: this text renders at 15.12px, so it needs 4.5:1, and
     0.45 composited over --ink is only 4.06:1. 0.52 gives 5.05:1. */
  .rb-footer-list:hover a { opacity: 0.52; }
  .rb-footer-list a:hover { opacity: 1; }
}

.rb-footer-list a:focus-visible { outline: 1px solid currentColor; outline-offset: 4px; }

/* Local time. Tabular figures so the minute ticking over never shifts the
   line sideways — the same reason the intro counter uses them. */
.rb-footer-clock {
  display: flex;
  align-items: baseline;
  gap: 8px;
  margin: 0;
  font-size: clamp(20px, 1.6vw, 26px);
  font-weight: 500;
  letter-spacing: -0.02em;
  font-variant-numeric: tabular-nums;
}

.rb-footer-zone {
  font-size: 11px;
  letter-spacing: 0.12em;
  text-transform: uppercase;
  opacity: 0.5;
}

/* The wordmark — full bleed, on the baseline of the page. space-between
   pushes the two words to the two edges, but it can only do that if
   they fit: the old 12.4vw plus a 4vw gap needed ~8.2em of a 7.8em
   measure, so "Shaiok" ran 30px off the right edge on every page at
   every width. The size is now derived instead of tuned — the two words
   measure 7.74em together in Neue Montreal at this tracking, so the
   footer's own width (cqi) over 8.1em leaves a ~0.35em gap that
   space-between then distributes. vw is the fallback for browsers
   without container units. */
.rb-footer-mark {
  display: flex;
  justify-content: space-between;
  gap: 0.25em;
  margin: clamp(48px, calc(var(--rb-vh) * 8), 120px) 0 0;
  font-size: 11.2vw;
  font-size: clamp(32px, calc(100cqi / 8.1), 230px);
  font-weight: 500;
  line-height: 0.8;
  letter-spacing: -0.055em;
  text-transform: uppercase;
  color: var(--rb-ink);
  opacity: 0.15;
  transition: opacity 0.5s ease;
}

/* letter-spacing is applied after the last glyph too, so with -0.055em
   tracking the final K's ink overhangs its own box by that much and ran
   ~8px past the right margin. Pull the box in by the same amount. */
.rb-footer-mark span:last-child { margin-right: 0.055em; }

@media (hover: hover) and (pointer: fine) {
  .rb-footer-mark:hover { opacity: 0.55; }
}

.rb-footer-mark:focus-visible { outline: 1px solid currentColor; outline-offset: 6px; }

.rb-footer-bottom {
  display: flex;
  flex-wrap: wrap;
  justify-content: space-between;
  align-items: center;
  gap: 12px;
  margin-top: clamp(28px, 3vw, 48px);
  padding-top: var(--rb-row-y);
  border-top: 1px solid var(--rb-rule);
}

/* A real button, because it performs an action rather than going
   anywhere — styled down to the micro-label beside it. */
.rb-footer-up {
  appearance: none;
  margin: 0;
  padding: 0;
  border: 0;
  background: none;
  color: inherit;
  font: inherit;
  /* Buttons don't inherit this from the UA sheet, so it read "Back to
     top" in a bar that is otherwise all caps. */
  text-transform: inherit;
  letter-spacing: inherit;
  cursor: pointer;
  display: inline-flex;
  align-items: center;
  gap: 8px;
  transition: opacity 0.28s ease;
}

@media (hover: hover) and (pointer: fine) {
  .rb-footer-up:hover { opacity: 0.55; }
}

/* ---------------------------------------------------------------------
   Page shell (About / Contact / project pages)
   --------------------------------------------------------------------- */

.rb-page {
  background: transparent;
  color: var(--rb-ink);
  font-family: var(--rb-font);
}

.rb-page-hero,
.rb-case-hero {
  padding: calc(var(--rb-section-y) + 60px) var(--rb-edge) var(--rb-section-y);
  background: var(--rb-ground);
}

.rb-page-hero-heading,
.rb-case-title { margin-top: var(--rb-stack); }

/* The case-study hero is a cover: one screen, the client and title at the
   top, and the statement and the project's facts pinned to the fold, so
   the reader takes in the whole brief before the first image arrives.
   It used to run to 1.4 screens on a laptop, which put the metadata row
   below the fold and left the title floating alone. */
.rb-case-hero {
  min-height: var(--rb-screen);
  display: flex;
  flex-direction: column;
  padding-top: calc(var(--rb-section-y) + 40px);
  padding-bottom: calc(var(--rb-section-y) * 0.6);
}

.rb-case-hero .rb-case-statement { margin-top: auto; padding-top: var(--rb-head-mb); }

.rb-page-hero-copy,
.rb-case-statement { margin-top: var(--rb-head-mb); }

/* Prose sections reuse the header grid so their label sits in the same
   column as every other kicker on the site. */
.rb-prose {
  display: grid;
  grid-template-columns: 2fr 1fr;
  gap: var(--rb-head-gap);
  align-items: start;
}

@media (max-width: 700px) {
  .rb-prose { grid-template-columns: 1fr; }
}

.rb-prose-body { display: flex; flex-direction: column; gap: var(--rb-stack); }
.rb-prose-label { order: 2; text-align: right; }
.rb-prose-body { order: 1; }

@media (max-width: 700px) {
  .rb-prose-label { order: 0; text-align: left; margin-bottom: var(--rb-stack); }
}

/* Contact details read as one divider row, like every other list. */
.rb-contact-list {
  display: grid;
  grid-template-columns: repeat(2, minmax(0, 1fr));
  gap: var(--rb-row-gap);
  margin: 0;
  padding: var(--rb-row-y) 0;
  border-top: 1px solid var(--rb-rule);
  border-bottom: 1px solid var(--rb-rule);
}

.rb-contact-list dt { margin-bottom: 10px; }
.rb-contact-list dd { margin: 0; }

@media (max-width: 700px) {
  .rb-contact-list { grid-template-columns: 1fr; gap: var(--rb-stack); }
}

.rb-columns {
  list-style: none;
  margin: 0;
  padding: 0;
  display: grid;
  gap: 8px var(--rb-row-gap);
  grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
}

/* CV rows use the shared row grid; the period sits in the lead column. */
.rb-cv-detail { max-width: 46ch; }

/* The role is the title of a CV entry, so it is set at title size rather
   than at the 13px reserved for the small index labels. Tracking goes
   tight and negative here (like .rb-title) — the wide 0.09em it inherited
   from the micro style is for 13px caps and falls apart at this size. */
.rb-cv-detail strong {
  display: block;
  margin-bottom: 10px;
  text-transform: uppercase;
  font-weight: 500;
  font-size: 25px;
  line-height: 1.05;
  letter-spacing: -0.02em;
}

/* 13px is the small-label size: the "02 // Craft" section indices, and the
   period / location cells that flank a CV row. Everything set in the micro
   style elsewhere on the site keeps its own 10px. */
.rb-head-side .rb-micro,
.rb-cv-period,
.rb-row > .rb-micro {
  font-size: 13px;
}

@media (max-width: 700px) {
  .rb-cv-detail strong { font-size: 21px; }
}

/* --- Page-hero statement ---------------------------------------------
   Type metrics copied verbatim from .hero-statement so a subpage hero
   reads at the same weight as the home page's: same clamp, same 0.85
   line-height, same -0.072em tracking, same uppercase.

   Two deliberate differences from the frozen original. It takes its ink
   from the palette instead of the hard #fff there — that white exists
   only because the home hero sits in mix-blend-mode: difference over the
   video, and there is no video here to invert against. And it is in
   normal flow rather than absolutely centred, because a subpage hero has
   content beneath it. */
.rb-hero-statement {
  margin: 0;
  color: var(--rb-ink);
  text-transform: uppercase;
  font: 500 clamp(40px, min(7.6vw, calc(var(--rb-vh) * 14)), 148px) / 0.85 var(--rb-font);
  letter-spacing: -0.072em;
}

.rb-hero-line {
  display: flex;
  width: 100%;
  justify-content: center;
  gap: 0.22em;
}

.rb-hero-word {
  display: inline-block;
  width: max-content;
  max-width: 100%;
}

/* Phones: "Visual storyteller." is 9.4em wide, which no legible size fits
   on a 375px screen — at the old 52px floor it ran 130px off the edge and
   "Developer." and "storyteller." were both cut. The line wraps instead,
   and the size tracks the width so the longest single word
   ("storyteller.", 5.9em) always fits. */
@media (max-width: 700px) {
  .rb-hero-statement { font-size: min(13.5vw, 64px); }
  .rb-hero-line { flex-wrap: wrap; row-gap: 0; }
}

/* The home hero's diagonal: first line flush left, last flush right. */
.rb-hero-statement.is-split .rb-hero-line:first-child { justify-content: flex-start; }
.rb-hero-statement.is-split .rb-hero-line:last-child { justify-content: flex-end; }

/* --- CV timeline rail -------------------------------------------------
   A track down the left of the CV list with a fill that scrubs as the
   section passes. The rail lives in padding added to the list, so the
   row grid itself is untouched and still lines up with every other
   .rb-rows block on the site. */
.rb-timeline { position: relative; padding-left: 44px; }

.rb-timeline-rail {
  position: absolute;
  left: 7px;
  top: 0;
  width: 2px;
  height: 100%;
  background: var(--rb-rule);
  /* Fades the track out at both ends so it does not butt hard into the
     section rules above and below it. */
  -webkit-mask-image: linear-gradient(to bottom, transparent 0%, #000 5%, #000 95%, transparent 100%);
  mask-image: linear-gradient(to bottom, transparent 0%, #000 5%, #000 95%, transparent 100%);
  pointer-events: none;
}

.rb-timeline-fill {
  position: absolute;
  inset: 0;
  background: var(--rb-ink);
  /* Full by default: if the scrub never runs — no JS, no ScrollTrigger,
     reduced motion — the rail reads as a finished line rather than an
     empty gutter. initTimelines() scales it to 0 before animating. */
  transform: scaleY(1);
  transform-origin: top;
}

/* Node per row, centred on the 2px track (track at left:7px, so its
   centre is 8px; a 16px dot therefore starts at 0). */
.rb-timeline .rb-row { position: relative; }

.rb-timeline .rb-row::before {
  content: '';
  position: absolute;
  left: -44px;
  top: calc(var(--rb-row-y) + 0.2em);
  width: 16px;
  height: 16px;
  box-sizing: border-box;
  border: 1px solid var(--rb-rule);
  border-radius: 50%;
  background: var(--rb-ground);
}

/* The period stays with its org while a multi-role entry scrolls past,
   the way the rail's own labels do on the reference component. */
.rb-timeline .rb-cv-period {
  position: sticky;
  top: 120px;
}

@media (max-width: 700px) {
  .rb-timeline { padding-left: 28px; }
  .rb-timeline .rb-row::before { left: -28px; width: 12px; height: 12px; }
  .rb-timeline-rail { left: 5px; }
  .rb-timeline .rb-cv-period { position: static; }
}

/* --- About page ------------------------------------------------------
   The About page is the one place a row's middle column holds a stack
   (org → role → description) rather than a single label, so these are
   modifiers on the shared .rb-row rather than a second row system. */

/* .rb-row centres its columns, which is right for the frozen capability
   list where every cell is one line. A CV row's middle column can run to
   three stacked roles, and centring floats the period and location into
   the vertical middle of that block, away from the org they label. */
.rb-rows--cv > .rb-row { align-items: start; }

.rb-cv-entry { display: flex; flex-direction: column; gap: 10px; }
.rb-cv-role { display: flex; flex-direction: column; gap: 6px; }

/* A progression (several roles at one org) is indented behind a rule so
   the steps read as belonging to the org above rather than as new rows. */
.rb-cv-steps {
  display: flex;
  flex-direction: column;
  gap: 18px;
  margin: 4px 0 0;
  padding: 0 0 0 20px;
  list-style: none;
  border-left: 1px solid var(--rb-rule);
}

.rb-cv-step { display: flex; flex-direction: column; gap: 6px; }

/* Tool marks sit in the row's narrow right-hand track, so they are square
   tiles on the shared rule rather than labelled cells — the name lives in
   each image's alt text, which is what a screen reader reads anyway. */
.rb-tools {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(52px, 1fr));
  gap: 16px;
  margin: 0;
  padding: 0;
  list-style: none;
}

/* The marks are drawn as a MASK over a cream fill rather than as <img>
   run through a filter. Two reasons:

   1. Colour. These are full-colour brand SVGs, so their internals cannot be
      reached with `fill`, and the filter route (brightness(0) invert(1))
      can only land on a flat #fff — there is no per-channel filter that
      gets to the palette's #fffdd6. Masking paints the mark with an actual
      background-color, so it is exactly var(--rb-ink), same cream as the
      type beside it, and it follows the token if that ever changes.
   2. Sizing control. Every icon shares a 1366x1366 viewBox, but the
      artwork inside each canvas fills a different fraction of it — from
      63% (Photoshop) to 97% (After Effects) — and the marks have very
      different proportions (Id is 1324x1262, Ps is 864x669). `contain`
      therefore normalises the CANVAS, not the mark, which is why Id, Pr
      and Lr read as much heavier than Figma or Ps.

      --rb-tool-fit carries a per-icon mask-size that corrects for this.
      Each value is target / geometric-mean-of-the-ink-box: the geometric
      mean is what balances a wide-short wordmark against a tall one, where
      matching on height alone would demand a 195% scale on Ps and crop it.
      Values were derived by measuring getBBox() on each file, and the
      target is backed off far enough that no mark reaches its tile edge.
      Re-measure if the icon set changes.

   The mark is decorative-with-meaning, so each <li> carries role="img" and
   an aria-label in place of the alt text the <img> used to provide. */
.rb-tool {
  aspect-ratio: 1;
  background-color: var(--rb-ink);
  -webkit-mask: var(--rb-tool) center / var(--rb-tool-fit, contain) no-repeat;
  mask: var(--rb-tool) center / var(--rb-tool-fit, contain) no-repeat;
}

@media (max-width: 700px) {
  .rb-tools { grid-template-columns: repeat(auto-fill, minmax(44px, 1fr)); gap: 12px; }
  .rb-cv-steps { padding-left: 14px; gap: 14px; }
}

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

.rb-form {
  display: grid;
  gap: var(--rb-row-gap);
  margin-top: var(--rb-head-mb);
}

/* Six tracks so one row can hold halves (name / email) and the next
   thirds (what / timeline / budget) — three short choices on one line
   instead of a full-width select on a row of its own, which is a whole
   field-height off the form. */
@media (min-width: 701px) {
  .rb-form { grid-template-columns: repeat(6, minmax(0, 1fr)); }
  .rb-field { grid-column: span 3; }
  .rb-field--third { grid-column: span 2; }
  .rb-field--full { grid-column: 1 / -1; }
}

/* Wide screens: the heading and the form side by side rather than
   stacked, so the whole form — heading, fields, send — is on one screen.
   Stacked it was 1.25–1.35 screens on every laptop. */
@media (min-width: 961px) {
  .rb-contact-form .rb-prose { grid-template-columns: minmax(0, 1fr) auto; }
  .rb-contact-form .rb-prose-body {
    display: grid;
    grid-template-columns: minmax(0, 0.85fr) minmax(0, 1.15fr);
    column-gap: clamp(32px, 4vw, 80px);
    align-items: start;
  }
  .rb-contact-form .rb-prose-body > :not(h2) { grid-column: 2; }
  .rb-contact-form .rb-prose-body > h2 { grid-row: 1 / span 3; }
  .rb-contact-form .rb-form { margin-top: 0; }
}

.rb-field { display: flex; flex-direction: column; gap: 10px; }

.rb-field input,
.rb-field select,
.rb-field textarea {
  width: 100%;
  padding: 12px 0;
  border: 0;
  border-bottom: 1px solid var(--rb-rule);
  border-radius: 0;
  background: transparent;
  color: var(--rb-ink);
  text-transform: lowercase;
  font: 400 var(--rb-body) / 1.55 var(--rb-font);
  letter-spacing: -0.01em;
  transition: border-color 0.3s ease;
}

.rb-field select { -webkit-appearance: none; appearance: none; cursor: pointer; }

/* appearance:none removes the native arrow, and nothing replaced it, so
   "Select one" read as static text over a rule rather than a menu. A
   caret in --cream (#f4eee3; a data URI cannot take a custom property). */
.rb-field select {
  padding-right: 24px;
  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='8' viewBox='0 0 12 8'%3E%3Cpath d='M1 1.5l5 5 5-5' fill='none' stroke='%23f4eee3' stroke-width='1.4'/%3E%3C/svg%3E");
  background-repeat: no-repeat;
  background-position: right 2px center;
  background-size: 12px 8px;
}
.rb-field select option { background: var(--rb-ground); color: var(--rb-ink); }
.rb-field textarea { resize: vertical; min-height: 5.5em; }
.rb-field input[type="email"] { text-transform: none; }

.rb-field input:focus,
.rb-field select:focus,
.rb-field textarea:focus { outline: none; border-bottom-color: var(--rb-ink); }

.rb-form-submit { grid-column: 1 / -1; justify-self: start; margin-top: 12px; }

.rb-form-success { display: none; margin-top: var(--rb-head-mb); }
.rb-form-success.is-visible { display: block; }

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

/* Project metadata reads as one divider row, same rule and padding as
   the frozen capability list. */
.rb-case-meta {
  display: grid;
  /* auto-fit, so the palette (where a project has one) takes a fifth
     column on the same row instead of a row of its own. */
  grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
  gap: var(--rb-row-gap);
  margin-top: var(--rb-head-mb);
  padding: var(--rb-row-y) 0;
  border-top: 1px solid var(--rb-rule);
  border-bottom: 1px solid var(--rb-rule);
}

@media (max-width: 700px) {
  .rb-case-meta { grid-template-columns: repeat(2, minmax(0, 1fr)); }
}

.rb-case-meta-key { display: block; margin-bottom: 10px; }
.rb-case-meta-val { display: block; }
.rb-case-palette { grid-column: auto; }

@media (max-width: 700px) {
  .rb-case-palette { grid-column: 1 / -1; }
}

.rb-case-gallery {
  display: flex;
  flex-direction: column;
  gap: var(--rb-section-y);
  padding: var(--rb-section-y) var(--rb-edge);
  background: var(--rb-ground);
}

.rb-case-figure { margin: 0; }
.rb-case-figure--wide { max-width: 100%; align-self: center; }
.rb-case-figure--inset { max-width: 74%; align-self: center; }
.rb-case-figure--right { max-width: 62%; align-self: flex-end; }

@media (max-width: 700px) {
  .rb-case-figure--inset,
  .rb-case-figure--right { max-width: 100%; align-self: center; }
}

/* Every image is seen whole. A full-bleed poster at 1265px wide ran
   ~840px tall — taller than a laptop screen — so its top and bottom were
   never on screen together. The cap is one screen less the nav band and
   the caption; under it, the image narrows and the figure shrink-wraps
   it, so the caption stays on the image's own left edge. The image is
   never cropped: this is the work itself. */
.rb-case-figure {
  width: fit-content;
  display: flex;
  flex-direction: column;
}
.rb-case-figure img {
  display: block;
  width: auto;
  max-width: 100%;
  height: auto;
  max-height: calc(var(--rb-screen) - 150px);
}
.rb-case-caption { display: block; margin-top: 12px; }

.rb-case-next {
  display: block;
  padding: var(--rb-section-y) var(--rb-edge);
  border-top: 1px solid var(--rb-rule);
  background: var(--rb-ground);
  color: inherit;
}

.rb-case-next-label { margin-bottom: var(--rb-stack); }
.rb-case-next-media { margin-top: var(--rb-head-mb); aspect-ratio: 16 / 7; }
.rb-case-next-cta { margin-top: var(--rb-head-mb); }

/* ---------------------------------------------------------------------
   Touch refinement.

   Keyed on (hover: none) rather than a width, because the thing being
   corrected is the input device, not the viewport: a large tablet has no
   hover and the same fat-finger target problem as a phone, while a narrow
   desktop window has a cursor and needs none of this.

   Nothing here changes desktop rendering, and nothing reaches inside the
   four frozen home-page sections.
   --------------------------------------------------------------------- */

/* Landscape notch. Every page ships viewport-fit=cover, which opts content
   in under the notch and the rounded corners; only the top and bottom
   insets were ever handled, so in landscape on a notched phone the left
   edge of every section ran under the sensor housing. max() keeps the
   normal edge padding everywhere the inset is 0. */
.rb-section,
.rb-page-hero,
.rb-case-hero,
.rb-case-gallery,
.rb-case-next,
.rb-footer {
  padding-left: max(var(--rb-edge), env(safe-area-inset-left));
  padding-right: max(var(--rb-edge), env(safe-area-inset-right));
}

/* The footer is the last thing on every page, so its final line sits right
   on the home indicator without the safe-area term — and under the
   sitewide bottom edge-blur without the band term. That blur is fixed to
   the viewport's bottom edge, and the bottom bar is the last line of the
   document, so it could never scroll clear of it: the copyright line and
   "Back to top" were permanently smeared on every page. The band height
   is MotionEnhancements.astro's .edge-blur height, mirrored here. */
.rb-footer {
  --rb-blur-band: clamp(2.75rem, 6vw, 5.5rem);
  padding-bottom: calc(env(safe-area-inset-bottom) + var(--rb-blur-band) + 4px);
}

@media (max-width: 700px) {
  .rb-footer { --rb-blur-band: clamp(2rem, 9vw, 3.25rem); }
}

@media (hover: none) {
  /* iOS Safari zooms the viewport whenever a focused field renders under
     16px, and leaves the page zoomed and horizontally pannable after the
     keyboard closes. --rb-body bottoms out at 14px, so the fields have to
     opt out of it here. 16px is the platform threshold, not a design pick
     — the same note in pages.css documents the rule but had no rule body. */
  .rb-field input,
  .rb-field select,
  .rb-field textarea { font-size: 16px; }

  /* 44px is the minimum comfortable touch target. These all render well
     under it: the roll-over links sit at 29px, the footer list at 17px.
     Padding rather than height, so the underline stays on the text
     baseline instead of floating away from it. */
  .rb-link {
    min-height: 44px;
    padding-top: 0.35em;
  }

  .rb-footer-list a,
  .rb-footer-list a.rb-nocase {
    display: inline-flex;
    align-items: center;
    min-height: 44px;
  }

  /* "Back to top" rendered 63×13. Its hit area grows with padding and the
     same amount is taken back in negative margin, so nothing beside it
     moves. (The nav's home link looks as small but already carries a
     ::after hit area in portfolio-experience.css.) */
  .rb-footer-up { padding: 15px 0; margin: -15px 0; }

  /* Hover is unreachable, so the roll-over and underline wipe never fire.
     A press state carries the feedback instead. */
  .rb-link:active { opacity: 0.6; }
  .rb-footer-list a:active { opacity: 0.6; }
}

/* =====================================================================
   Devices — iPhone 17 Pro + MacBook Air, scroll-driven.

   TWO LAYERS PER DEVICE
   ---------------------
   .rb-dev-screen holds live content; .rb-dev-frame sits above it with the
   photographic render as a background-image. The render must be a PNG (or
   WebP) whose screen area is cut to FULL TRANSPARENCY — the interface
   shows through the hole while the render's own bezel, glass and
   reflections stay above it. The frame is pointer-events:none, so clicks
   land on the interface underneath.

   CALIBRATING A RENDER — four numbers per device, nothing else:
     --dev-w / --dev-h        the render's own pixel dimensions. The box
                              must match the render's aspect ratio or the
                              screen rect will not line up with the hole.
     --dev-screen-t/-l/-w/-h  the transparent hole, as a percentage of
                              that box.
     --dev-screen-r           corner radius of the hole.

   SCALE: hardware is sized in real pixels off --rb-u, a length that
   clamps with the viewport, so a narrower window re-lays-out the devices
   instead of transform-scaling them and the interfaces stay crisp. The
   scroll entrance is the only transform-scale, and it resolves to exactly
   scale(1), so the resting state is pixel-true and hit-tests exactly.

   The two --dev-h values are deliberately different (see .rb-dev--laptop
   below); align-items:center on the stage is what lines the pair up.
   ===================================================================== */

.rb-devices {
  /* The section sits after the brands strip now, not in the hero's fade
     slot, so the 60vh of clearance that used to keep the devices out from
     behind the still-fading .hero-stage is gone. What is left is breathing
     room: the machine is pinned and centred by its own ScrollTrigger, so
     this padding only sets how far the strip above it clears the frame. */
  padding-top: calc(var(--rb-section-y) * 1.5);

  /* The devices start pushed 30vw past their own edge, which would
     otherwise widen the document. `clip` rather than `hidden`: hidden
     makes this a scroll container, and ScrollTrigger pins the stage
     inside it. `overflow-x: clip` keeps overflow-y visible (a
     hidden/visible pair would compute to auto and break the pin). */
  overflow-x: clip;
}

.rb-dev-stage {
  position: relative;
  display: flex;
  /* Bottom-aligned, not centred: the two devices are different heights
     now, and sharing a floor is both physically right and what keeps
     their labels on one line. */
  align-items: flex-end;
  justify-content: center;
  gap: calc(56 * var(--rb-u));

  /* Reserves the label band inside the stage's own box. The labels are
     absolutely positioned, so without this they sit outside the height
     the pin centres, and end up in the bottom blur. */
  padding-bottom: calc(40 * var(--rb-u));

  perspective: 2000px;
  perspective-origin: 50% 46%;

  /* Sized to FILL the space between the chrome, not to sit politely
     inside it — the devices have to be big enough to actually use once
     interfaces are mounted in the screens. Both terms are real bounds,
     and whichever is tighter wins:

     Width — the laptop measures 618 units across (the phone moved to the
     Approach section), and 140px is left for the section's edge padding
     plus a margin, so it spans the column without touching either edge.

     Height — the machine is 379 units tall plus the 40-unit label band,
     and the viewport has to hold it with room above and below, so 120px
     comes off before the division.

     Height is what binds on a normal laptop display; width only takes
     over on a short, wide window. The ceiling exists so the render is
     never asked to paint above its own 1800px export. */
  /* Lifted above the edge blur (z-index 4000) rather than sized to duck
     under it. The devices stay crisp either way, and it frees the ~176px
     the two bands were costing — the phone screen goes from 0.69 to 0.79
     of the Figma frame, which is the difference between the interface
     being legible and not. The nav sits at 9000 and still wins. */
  z-index: 4100;

  --rb-u: clamp(
    0.6px,
    min(
      (100vw - 140px) / 618,
      (var(--rb-screen) - 120px) / 419
    ),
    2.4px
  );
}

.rb-dev {
  position: relative;
  width: calc(var(--dev-w) * var(--rb-u));
  height: calc(var(--dev-h) * var(--rb-u));

  /* Never let the flex row resize these. --rb-u is the only thing allowed
     to change their size: a shrunk box no longer matches its render's
     aspect ratio, `background-size: contain` then letterboxes inside it,
     and the screen slot — positioned in percentages of the box — drifts
     off the actual display. */
  flex: none;

  transform-style: preserve-3d;
  will-change: transform, opacity;
}

/* Cleared once the entrance settles — a permanent will-change pins a
   compositor layer for the whole page life for no further benefit. */
.rb-dev.is-live { will-change: auto; }

/* Interaction gate. Scoped to .is-armed, which initDevices() adds, so a
   no-JS page never ends up with permanently dead screens. */
.rb-dev-stage.is-armed .rb-dev:not(.is-live) { pointer-events: none; }

/* --- Per-device geometry ---------------------------------------------
   Measured off the supplied artwork, not estimated:

   IPhone_17.svg — true vector, viewBox 72.5 × 150. Its display is the
   path #rect32-2 at x 2.91, y 2.46, 66.68 × 145.07 (read via getBBox),
   which is where the four percentages below come from. The machine fills
   its viewBox edge to edge, so --dev-h IS the rendered height.

   The laptop's numbers are measured off the file that actually loads —
   Preview/MacBook_Air_M4.webp, 1800 × 1103 — NOT off the SVG it came
   from. svg-to-webp.mjs pulls the embedded 3000 × 1837 raster straight
   out of the data URI rather than rendering the SVG, so the WebP carries
   the raster's aspect (1.63191) and not the SVG viewBox's (1.59952), and
   it has none of the viewBox's transparent padding. Calibrating against
   the SVG left --dev-h too tall: `contain` then letterboxed the artwork
   3.83 units up and down inside its own box, and since --dev-screen-*
   are percentages OF THE BOX, the slot sat low and short — a band of the
   render's macOS wallpaper stayed visible below the mounted interface.

   The display glass, found by scanning the WebP's own pixels for the last
   non-black column/row against the bezel, is x 162 → 1634, y 30 → 984.
   The slot below is deliberately 2px LARGER than that on every side —
   x 160 → 1637, y 26 → 987 — because the glass edge carries the
   wallpaper's brightest cyan, and the slot's edges land on fractional
   device pixels once --rb-u scales (it runs up to 2.2px, at which point
   one image row is a full CSS pixel). Sitting exactly on the glass left a
   cyan hairline along the bottom edge wherever rounding went the wrong
   way. Two pixels of overshoot land in pure black bezel, so the worst a
   rounding error can now reveal is black.

   The overshoot is free: .kb/.sp fill the slot with their own ground, and
   their content is positioned in --kb-u/--sp-u units from the top-left,
   so a slightly larger slot adds ground at the edges rather than
   rescaling or shifting the interface.

   Percentages are that slot over 1800 × 1103. If the render is ever
   re-exported, re-measure — do not carry these over.
   --------------------------------------------------------------------- */

.rb-dev--phone {
  --dev-w: 213.15;          /* 441 × (72.5 / 150) */
  --dev-h: 441;
  --dev-screen-l: 4.014%;   /* 2.91 / 72.5  */
  --dev-screen-t: 1.640%;   /* 2.46 / 150   */
  --dev-screen-w: 91.972%;  /* 66.68 / 72.5 */
  --dev-screen-h: 96.713%;  /* 145.07 / 150 */
  --dev-screen-r: calc(30.6 * var(--rb-u));
  --dev-frame: url('Preview/IPhone_17.svg');
  z-index: 2;
}

/* Deliberately NOT matched to the phone's height any more. At equal
   heights the laptop is 3.4× the phone's width and swallowed the
   composition; a real 13" Air would actually be 4.2×, so this is a
   compositional call, not a physical one. 618 units puts it at 2.9× the
   phone and 373 tall against the phone's 441. */
.rb-dev--laptop {
  --dev-w: 618;
  --dev-h: 378.70;          /* 618 × 1103/1800 — the WebP's exact aspect,
                               so `contain` fills the box edge to edge and
                               there is no letterbox to offset the slot. */
  --dev-screen-l: 8.889%;   /* 160 / 1800  — glass 162, less 2px bleed */
  --dev-screen-t: 2.357%;   /* 26 / 1103   — glass 30,  less 2px bleed */
  --dev-screen-w: 82.111%;  /* 1478 / 1800 — x 160 → 1637 */
  --dev-screen-h: 87.217%;  /* 962 / 1103  — y 26 → 987 */
  --dev-screen-r: calc(7 * var(--rb-u));
  /* WebP, not the original SVG. That file was a 3000×1838 PNG base64'd
     inside an SVG wrapper — 5.1MB, ~3.7× oversampled for the 819px it
     ever renders at. Re-encoded to 1800px wide it is 191KB, 27× smaller,
     alpha intact. The source lives in source-assets/device-art/ (git-
     ignored, and crucially outside public/, which ships wholesale to
     dist/). Regenerate with scripts/svg-to-webp.mjs. */
  --dev-frame: url('Preview/MacBook_Air_M4.webp');
}

/* The screen sits ABOVE the frame. Both supplied files have an opaque
   display — the phone's is flat grey, the laptop's carries the stock
   macOS wallpaper — so there is no transparent hole to show an interface
   through. Layering the slot on top covers that artwork instead. If the
   screens are ever cut out to true transparency, swap these two
   z-indexes and the frame's glass and reflections come back over the
   live content. */
.rb-dev-screen {
  position: absolute;
  z-index: 2;
  top: var(--dev-screen-t);
  left: var(--dev-screen-l);
  width: var(--dev-screen-w);
  height: var(--dev-screen-h);
  border-radius: var(--dev-screen-r);
  overflow: hidden;
  isolation: isolate;

  /* Transparent until an interface mounts, so the artwork's own display
     shows through — the laptop keeps its wallpaper and the phone its
     switched-off grey. An opaque app will cover both. */
  background: transparent;
}

.rb-dev-frame {
  position: absolute;
  inset: 0;
  z-index: 1;
  background-image: var(--dev-frame);
  /* `contain`, not `100% 100%`: a render whose aspect doesn't match
     --dev-w/--dev-h letterboxes visibly instead of silently stretching,
     which makes a miscalibrated box obvious rather than subtle. */
  background-size: contain;
  background-position: center;
  background-repeat: no-repeat;
  pointer-events: none;
}

/* --- Copy -------------------------------------------------------------
   Both the cue and the device names are tied to .is-settled, which
   initDevices() holds on the stage for exactly the stretch between the
   intro and the outro. Naming a device while it is turned away and moving
   only clutters the frame, and the cue would be telling you to interact
   with something that isn't interactive yet.
   --------------------------------------------------------------------- */

.rb-dev-label {
  opacity: 0;
  transition: opacity 0.55s ease;
  pointer-events: none;
}

.rb-dev-stage.is-settled .rb-dev-label { opacity: 1; }

/* The cue sits dead centre of the stage — the gap the two half-off-frame
   devices leave open at the side pose — and is scrubbed out by the
   timeline as they converge on it, then back in on the outro. Its opacity
   is owned by GSAP, not by .is-settled like the labels, because it has to
   be *timed against* the devices arriving rather than just switched at
   the end of the move.

   Behind the devices (z-index 0) so they pass over it rather than under,
   and starts at 0 so nothing flashes before initDevices() takes over.
   Running prose, so lowercase — the frozen sections reserve uppercase for
   headings and labels (see .hero-supporting). */
.rb-dev-cue {
  position: absolute;
  z-index: 0;
  left: 50%;
  top: 50%;
  /* Wraps to a compact block rather than one long line. The gap the side
     pose leaves is off-centre — the laptop is far wider than the phone —
     so a full-width line ran to within 48px of the laptop while leaving
     a void on the left. */
  max-width: 22ch;
  margin: 0;
  transform: translate(-50%, -50%);
  text-align: center;
  text-wrap: balance;
  color: var(--rb-ink);
  text-transform: lowercase;
  font: 500 clamp(19px, 2vw, 30px) / 1.3 var(--rb-font);
  letter-spacing: -0.01em;
  opacity: 0;
  pointer-events: none;
}

/* Product names — .project-category's micro-label treatment. Centred
   under each device rather than beside it: at full size the pair leaves
   only ~80px either side of the viewport, which will not hold a word. */
.rb-dev-label {
  position: absolute;
  top: 100%;
  left: 50%;
  margin: calc(24 * var(--rb-u)) 0 0;
  transform: translateX(-50%);
  white-space: nowrap;
  color: var(--rb-ink);
  text-transform: uppercase;
  /* .rb-micro's treatment, stepped up from its 10px — at the devices'
     full size a 10px caption read as a stray mark rather than a name. */
  font: 500 12px / 1.25 var(--rb-font);
  letter-spacing: 0.09em;
}

.rb-dev-shadow {
  position: absolute;
  left: 50%;
  bottom: calc(-26 * var(--rb-u));
  width: 88%;
  height: calc(38 * var(--rb-u));
  transform: translateX(-50%);
  background: radial-gradient(closest-side, rgba(0, 0, 0, 0.6), transparent 78%);
  filter: blur(calc(8 * var(--rb-u)));
  pointer-events: none;
}

/* =====================================================================
   Swiftpay — the interface on the phone.

   AUTHORED IN DESIGN PIXELS. The Figma artboard is 402 x 874, so --sp-u
   is "one design pixel" and every number below is read off the node. The
   phone's screen slot is 402 x 0.48766 of --rb-u wide (--dev-w 213.15 x
   --dev-screen-w 91.972%), which is where that constant comes from —
   change the device size and this rescales with it, exactly.

   This is the second pass. The first was reconstructed from an exported
   PDF: positions came off the operator list, and the action glyphs,
   chip, card plate, transaction rows and tab bar were rebuilt by hand in
   CSS and inline SVG. Working from the design file directly replaces all
   of that with the file's own artwork — the four action tiles (ring,
   glyph and label are one vector each), the card's lettering and chip,
   the transaction rows' type, the tab bar shell and the QR button. What
   stays hand-built is only what the design itself builds out of
   primitives: the status bar's battery, the burger, and the coloured
   plate behind the card.

   Blocks are absolutely positioned at their measured x/y through --x and
   --y, the same as Kursbit below. That is not how one would normally
   build a screen, but it is what makes this a reproduction rather than
   an approximation: every figure is the design's own, so a diff against
   the node is a like-for-like check.
   ===================================================================== */

.sp {
  --sp-u: calc(0.48766 * var(--rb-u));
  --sp-ink: #fff;
  --sp-dim: #a3a3a3;
  --sp-mute: #737373;
  --sp-link: #1da64d;
  --sp-in: #2ecc71;
  --sp-out: #e74c3c;

  position: relative;
  width: 100%;
  height: 100%;
  overflow: hidden;
  background: #121212;
  color: var(--sp-ink);
  font-family: 'Cedora', 'Neue Montreal', Arial, sans-serif;
  font-weight: 400;
  text-transform: none;
  letter-spacing: 0;
  -webkit-font-smoothing: antialiased;
}

/* :where() so this contributes no specificity. As '.sp button' it scored
   (0,1,1) and outranked every '.sp-*' (0,1,0) font-size, so 'font:
   inherit' silently reset every button to the inherited 16px. */
.sp :where(button) { font: inherit; color: inherit; background: none; border: 0; padding: 0; cursor: pointer; }
.sp :where(p) { margin: 0; }
.sp a { color: inherit; text-decoration: none; }
.sp img { display: block; }

/* Placement helpers, shared by everything below. .sp-b positions from
   --x/--y; .sp-r also sizes from --w/--h; .sp-i is a leaf image filling
   whichever .sp-r box it sits in. */
.sp-b { position: absolute; left: calc(var(--x) * var(--sp-u)); top: calc(var(--y) * var(--sp-u)); }
.sp-r { position: absolute; left: calc(var(--x) * var(--sp-u)); top: calc(var(--y) * var(--sp-u));
        width: calc(var(--w) * var(--sp-u)); height: calc(var(--h) * var(--sp-u)); }
.sp-i { width: 100%; height: 100%; }

/* A run of live type placed by its INK box rather than its line box.

   The design outlines this lettering, so the node gives a glyph bounding
   box, not a text frame — and a line box is taller than its ink and
   starts above it. line-height: 0 collapses the leading, which puts the
   baseline a known (ascent − descent)/2 below the element's top, so --y
   is the ink top plus that offset plus the string's own ascent, and --x
   carries the left side bearing the same way. Both are computed per
   string.

   Sizes come from measuring Cedora against each outline at 1000px and
   scaling down — Chrome quantises canvas ink metrics to integers, which
   is far too coarse to fit a 9px label — and --ls trims the residue, so
   every run lands on the design's width to three decimals.

   These were the last twelve exports in the screen. They are gone now
   because the lettering is the product's own content rather than
   artwork: as live text it stays selectable, scales without
   resampling, and survives a copy change. */
.sp-tx { position: absolute; left: calc(var(--x) * var(--sp-u)); top: calc(var(--y) * var(--sp-u));
         font-size: calc(var(--fs) * var(--sp-u)); line-height: 0;
         letter-spacing: calc(var(--ls) * var(--sp-u)); white-space: nowrap; }

/* A text node: the design's own size, leading and tracking. .sp-tc adds
   the box width the type is centred inside where the node gives one. */
.sp-t  { font-size: calc(var(--fs) * var(--sp-u)); line-height: calc(var(--lh) * var(--sp-u));
         letter-spacing: calc(var(--ls, 0) * var(--sp-u)); white-space: nowrap; }
.sp-tc { width: calc(var(--w) * var(--sp-u)); text-align: center; }

/* --- Status bar ------------------------------------------------------
   Inter Semi Bold in the design; there is no Inter in the repo, so the
   platform UI face stands in — this is the one strip of the screen meant
   to read as the OS rather than as the product.
   --------------------------------------------------------------------- */
.sp-time {
  --x: 28; --y: 16.25; --w: 41.95; --h: 23;
  display: flex;
  align-items: center;
  font: 600 calc(15 * var(--sp-u)) / calc(22.5 * var(--sp-u)) system-ui, -apple-system, 'Segoe UI', sans-serif;
  letter-spacing: calc(-0.375 * var(--sp-u));
}
.sp-cell { --x: 290; --y: 22.5; --w: 16; --h: 12; }
.sp-wifi { --x: 312; --y: 22.5; --w: 16; --h: 12; }
/* Four primitives in the design, so four here. */
.sp-batt {
  --x: 334; --y: 22.5; --w: 24; --h: 11;
  border: 1px solid rgba(255, 255, 255, 0.9);
  border-radius: calc(3 * var(--sp-u));
  box-sizing: border-box;
}
.sp-batt i {
  position: absolute;
  left: calc(1 * var(--sp-u)); top: calc(1 * var(--sp-u));
  width: calc(16 * var(--sp-u)); height: calc(7 * var(--sp-u));
  border-radius: calc(1.5 * var(--sp-u));
  background: #fff;
}
.sp-batt-nub {
  --x: 358.5; --y: 26; --w: 1.5; --h: 4;
  border-radius: 0 calc(2 * var(--sp-u)) calc(2 * var(--sp-u)) 0;
  background: rgba(255, 255, 255, 0.9);
}

/* --- Header ----------------------------------------------------------- */
.sp-avatar { --x: 24; --y: 65; --w: 33; --h: 33; border-radius: 50%; object-fit: cover; }
.sp-greet  { --x: 69.708; --y: 62.575; --fs: 10; --lh: 16;   --ls: 0.6;    color: var(--sp-dim); }
.sp-name   { --x: 69.708; --y: 70.786; --fs: 19; --lh: 28.5; --ls: -0.475; }

/* Three bars, each at its own x, y and width — the design does not stack
   them on a shared pitch, and the middle one is inset from the left. */
.sp-menu { --x: 339; --y: 63; --w: 38; --h: 38; }
.sp-menu span {
  position: absolute;
  left: calc(var(--bx) * var(--sp-u));
  top: calc(var(--by) * var(--sp-u));
  width: calc(var(--bw) * var(--sp-u));
  height: calc(2 * var(--sp-u));
  border-radius: 9999px;
  background: var(--sp-ink);
  transition: transform 0.2s ease;
}
.sp-menu span:nth-child(1) { --bx: 16; --by: 9;  --bw: 20; }
.sp-menu span:nth-child(2) { --bx: 22; --by: 17; --bw: 14; }
.sp-menu span:nth-child(3) { --bx: 16; --by: 25; --bw: 20; }
.sp-menu:hover span:nth-child(2) { transform: translateX(calc(-6 * var(--sp-u))); }

/* --- Balance ---------------------------------------------------------- */
.sp-bal-label { --x: 24; --y: 112; --fs: 16; --lh: 19; --ls: -0.475; }
.sp-bal-amt {
  --x: 24; --y: 136; --w: 244; --h: 50; --fs: 36; --lh: 47.5; --ls: -0.95;
  display: flex;
  align-items: center;
  justify-content: center;
  font-weight: 700;
}
/* The design ships one eye glyph — the struck-through "hide" state,
   mirrored on the x axis. There is no second state in the file to swap
   to, so the button toggles the figure and leaves the glyph as drawn. */
.sp-eye { --x: 307.146; --y: 149.770; --w: 26.119; --h: 23.032; transition: opacity 0.18s ease; }
.sp-eye:hover { opacity: 0.72; }
.sp-eye img { width: 100%; height: 100%; transform: scaleX(-1); }

.sp-viewall { --x: 24; --y: 186; --w: 110; --fs: 12; --lh: 16; color: var(--sp-link); }

/* --- Quick actions ---------------------------------------------------
   One vector per action: the ring, its glyph and the label underneath
   are a single artwork in the design, so they are a single <img> here.
   Each carries a little stroke bleed outside its own box (-0.24% at the
   sides, -0.18% on top), which is why the image is inset rather than
   sized to 100%.
   --------------------------------------------------------------------- */
.sp-act { --w: 51.735; transition: transform 0.2s ease, filter 0.2s ease; }
.sp-act img { position: absolute; left: -0.24%; top: -0.18%; width: 100.48%; height: 100.18%; }
.sp-act:hover  { filter: brightness(1.18); }
.sp-act:active { transform: scale(0.95); }
.sp-act--send     { --x: 28.675;  --y: 221.577; --h: 68.558; }
.sp-act--receive  { --x: 126.571; --y: 221.577; --h: 67.958; }
.sp-act--exchange { --x: 224.468; --y: 220.552; --h: 69.610; }
.sp-act--topup    { --x: 322.364; --y: 221.577; --h: 68.713; }

/* --- Section headers -------------------------------------------------- */
.sp-h-cards    { --x: 27;  --y: 313; --fs: 18; --lh: 28; --ls: -0.45; }
.sp-h-txns     { --x: 29;  --y: 619; --fs: 18; --lh: 28; --ls: -0.45; }
.sp-viewall-sm { --x: 339; --y: 625; --w: 45; --fs: 12; --lh: 16; font-weight: 700; color: var(--sp-link); }

/* Glyph and label are separate nodes 4.36 apart, so the button spans
   both and places each. */
.sp-add { --x: 310.776; --y: 320.056; --w: 70.782; --h: 16.413; color: var(--sp-link); transition: opacity 0.18s ease; }
.sp-add:hover { opacity: 0.75; }
.sp-add img   { position: absolute; left: 0; top: calc(3.078 * var(--sp-u));
                width: calc(10.258 * var(--sp-u)); height: calc(10.258 * var(--sp-u)); }
.sp-add span  { --w: 56.420; --fs: 13; --lh: 16.413; position: absolute; right: 0; top: 0; font-weight: 700; }

/* --- Card ------------------------------------------------------------
   Three layers, in the design's own order:

   1. The green plate: a rounded rectangle carrying a watermark image
      with the big lettering laid over it. Only its top ~21px is ever
      seen — the metal card covers the rest — but it is built in full,
      because that strip is what the eye reads as the card's edge.
   2. The metal card. Its texture is placed larger than the card and
      cropped: the design puts a 405.886 x 287.619 image at -41.897 /
      -14.493 inside a 352.883 x 224.350 window, and those four figures
      are reproduced rather than approximated with `cover`.
   3. The lettering: mark, wordmark, chip, numbers, names and the VISA
      block, each the design's own vector at its own measured box.

   The plate's green is a solid fill under the watermark, not the
   watermark stretched: stretching pushed its pale ghosting up into the
   visible strip, reading #86d974 where the design reads #4dc253.
   --------------------------------------------------------------------- */
.sp-plate {
  --x: 29.701; --y: 352.504; --w: 349.521; --h: 166.584;
  border-radius: calc(14.4804 * var(--sp-u)) / calc(10.8526 * var(--sp-u));
  overflow: hidden;
  background-color: #4dc253;
  background-image: url('Preview/swiftpay/card-green.webp');
  background-repeat: no-repeat;
  background-size: 100% auto;
  background-position: top center;
}
.sp-plate-word { --x: 2.514; --y: 0.291; --w: 347.411; --h: 60.873; }

.sp-card { --x: 28.675; --y: 374.190; --w: 352.883; --h: 224.350; overflow: hidden; border-radius: calc(20.2943 * var(--sp-u)); isolation: isolate; }
.sp-card img {
  position: absolute;
  left: calc(-41.897 * var(--sp-u));
  top: calc(-14.493 * var(--sp-u));
  width: calc(405.886 * var(--sp-u));
  height: calc(287.619 * var(--sp-u));
  max-width: none;
}
/* Not an outline, despite the name the export arrives under: this is the
   design own black rounded rectangle at 56%, laid over the texture. It is
   what makes the card read charcoal when the texture itself exports at a
   mean brightness of 168. The first pass had no such layer and baked an
   equivalent tone into the WebP instead — see the note in
   scripts/optimise-swiftpay-assets.mjs. */
.sp-card-edge { --x: 29; --y: 374; --w: 352; --h: 224; pointer-events: none; }

.sp-mark     { --x: 50.425;  --y: 395.681; --w: 16.207;  --h: 26.278; }
.sp-word     { --x: 75.233;  --y: 397.010; --w: 113.543; --h: 25.655; }
/* Labels and values. Three independent strings put the labels at 9.08 and
   three put the values at 17.45 — agreement close enough to treat as the
   design's two sizes rather than fitting each run on its own. */
.sp-num-l    { --x: 50.398;  --y: 514.267; --fs: 9.08;  --ls: 0.008; }
.sp-holder-l { --x: 51.314;  --y: 556.450; --fs: 9.08;  --ls: 0.005; }
.sp-exp-l    { --x: 169.971; --y: 556.039; --fs: 9.08;  --ls: -0.011; }
.sp-num-v    { --x: 50.488;  --y: 531.677; --fs: 17.45; --ls: 0.003; }
.sp-holder-v { --x: 51.355;  --y: 573.798; --fs: 17.45; --ls: 0.011; }
.sp-exp-v    { --x: 169.945; --y: 574.166; --fs: 17.45; --ls: -0.022; }
.sp-visa     { --x: 295.580; --y: 544.104; --w: 66.547;  --h: 21.553; }
.sp-platinum { --x: 327.736; --y: 571.373; --w: 33.744;  --h: 6.256; }

/* The chip is eight overlapping vectors in the design, sharing a
   51..101 / 457..493 footprint. Kept as eight rather than merged, so
   each lands on its own measured box. */
.sp-chip-1 { --x: 51; --y: 457; --w: 50; --h: 36; }
.sp-chip-2 { --x: 69; --y: 458; --w: 15; --h: 34; }
.sp-chip-3 { --x: 52; --y: 469; --w: 16; --h: 12; }
.sp-chip-4 { --x: 84; --y: 469; --w: 16; --h: 12; }
.sp-chip-5 { --x: 52; --y: 458; --w: 19; --h: 10; }
.sp-chip-6 { --x: 52; --y: 482; --w: 19; --h: 10; }
.sp-chip-7 { --x: 80; --y: 458; --w: 20; --h: 10; }
.sp-chip-8 { --x: 80; --y: 482; --w: 20; --h: 10; }

/* --- Transactions ----------------------------------------------------- */
.sp-txns {
  --x: 28; --y: 648; --w: 357; --h: 147;
  border: 1px solid rgba(38, 38, 38, 0.8);
  border-radius: calc(24 * var(--sp-u));
  background: #17181a;
  box-sizing: border-box;
}
.sp-txn-av { --x: 46; --y: 666; --w: 38; --h: 38; border: 1px solid #404040; border-radius: 9999px; overflow: hidden; box-sizing: border-box; }
/* The design crops the portrait inside the ring rather than fitting it,
   so these four figures are the node's own, not object-fit: cover. */
.sp-txn-av img { position: absolute; left: -2.18%; top: 2.27%; width: 105.26%; height: 113.72%; max-width: none; }

.sp-spot { --x: 45; --y: 738; --w: 40; --h: 40; border-radius: 9999px; background: #1db954; }
.sp-spot img { position: absolute; left: calc(8 * var(--sp-u)); top: calc(8 * var(--sp-u));
               width: calc(24 * var(--sp-u)); height: calc(24 * var(--sp-u)); }

/* Both dates measure 10.98 and both amounts 13.15. The two names do not
   agree with each other (14.88 against 13.96), so the outlines were not
   set in Cedora; they share one size here, because two sizes in a
   two-row list would read as a mistake, and --ls carries the difference.
   At the rendered scale that is a quarter of a pixel. */
.sp-t1-n { --x: 99.220;  --y: 676.685; --fs: 14.4;  --ls: 0.264; }
.sp-t2-n { --x: 98.955;  --y: 749.600; --fs: 14.4;  --ls: -0.228; }
.sp-t1-d { --x: 98.985;  --y: 695.421; --fs: 10.98; --ls: -0.001; color: var(--sp-mute); }
.sp-t2-d { --x: 98.985;  --y: 768.421; --fs: 10.98; --ls: -0.001; color: var(--sp-mute); }
.sp-t1-a { --x: 288.054; --y: 685.839; --fs: 13.15; --ls: -0.021; color: var(--sp-in); }
.sp-t2-a { --x: 301.114; --y: 758.839; --fs: 13.15; --ls: 0.015;  color: var(--sp-out); }
.sp-rule { --x: 45;      --y: 721;     --w: 322;    --h: 1; }

/* --- Tab bar ---------------------------------------------------------
   Shell, QR button and both tab glyphs are the design's own vectors. The
   QR export runs y 752 to 862, so it carries the button, the notch it
   cuts in the bar and the caption underneath as one piece — which is
   also why it sits after the shell in the markup.

   Each tab spans its icon and label together (the Finance pair is a
   group in the file, and its bounds are used verbatim), so the two
   pieces move as one and the button has a real hit area.
   --------------------------------------------------------------------- */
.sp-tabbar { --x: 3; --y: 799; --w: 398; --h: 75; }
.sp-tab    { transition: filter 0.2s ease; }
.sp-tab:hover { filter: brightness(1.35); }
.sp-tab img { position: absolute; left: calc(var(--ix) * var(--sp-u)); top: calc(var(--iy) * var(--sp-u));
              width: calc(var(--iw) * var(--sp-u)); height: calc(var(--ih) * var(--sp-u)); }
.sp-tab--home { --x: 58.094;  --y: 821;     --w: 23.219; --h: 31.638; }
.sp-tab--fin  { --x: 319.615; --y: 822.314; --w: 30.686; --h: 31.807; }
.sp-tab--home .sp-tab-ic { --ix: 0.906; --iy: 0;      --iw: 21;     --ih: 20; }
.sp-tab--home .sp-tab-l  { --ix: 0;     --iy: 25.426; --iw: 23.219; --ih: 6.212; }
.sp-tab--fin  .sp-tab-ic { --ix: 5.765; --iy: 0;      --iw: 18.874; --ih: 20.726; }
.sp-tab--fin  .sp-tab-l  { --ix: 0;     --iy: 25.506; --iw: 30.686; --ih: 6.301; }

.sp-qr { --x: 164.083; --y: 751.927; --w: 73.859; --h: 110.543; transition: transform 0.2s ease, filter 0.2s ease; }
.sp-qr:hover  { filter: brightness(1.25); }
.sp-qr:active { transform: scale(0.96); }

/* =====================================================================
   Kursbit — the interface on the laptop.

   AUTHORED IN DESIGN PIXELS, by the same method as Swiftpay above. The
   Figma artboard is a MacBook Pro 16" frame, 1728 x 1117, so --kb-u is
   "one design pixel" and every number below is read off the Figma node
   rather than guessed as a percentage.

   The laptop's screen slot measures --dev-w 618 x --dev-screen-w
   81.778% = 505.39 --rb-u across, and 505.39 / 1728 = 0.29247. Change
   the device size and the whole interface rescales with it, exactly.

   The slot's aspect (1.543) is a hair squarer than the artboard's
   (1.547), so filling it uncovers about 3 design px past the artboard's
   bottom edge. The app's own #222 ground runs through there and nothing
   is laid out in it — the feed is clipped well above.

   Blocks are absolutely positioned at their measured x/y through --x and
   --y. As with Swiftpay that is not how one would normally build a
   screen, but it is what makes this a reproduction rather than an
   approximation: every figure is the design's own, so a diff against the
   Figma node is a like-for-like check.
   ===================================================================== */

.kb {
  --kb-u: calc(0.29247 * var(--rb-u));

  /* Straight off the node — not reinterpreted into the site palette,
     which is rather the point of a product shot. */
  --kb-ground: #222;
  --kb-ink: #fff;
  --kb-dim: #676767;
  --kb-grey: #727272;
  --kb-green: #2e961b;
  --kb-red: #ff4d4d;
  --kb-sub: #d9d9d9;
  --kb-rule: #e9e9e9;

  position: relative;
  width: 100%;
  height: 100%;
  overflow: hidden;
  background: var(--kb-ground);
  color: var(--kb-ink);
  font-family: 'Montserrat', 'Neue Montreal', Arial, sans-serif;
  font-weight: 400;
  text-transform: none;
  letter-spacing: 0;
  -webkit-font-smoothing: antialiased;
}

/* :where() so none of these contribute specificity — the same trap the
   Swiftpay button reset documents above. */
.kb :where(button) { font: inherit; color: inherit; background: none; border: 0; padding: 0; cursor: pointer; }
.kb :where(p, ul, li, h3) { margin: 0; padding: 0; }
.kb :where(ul) { list-style: none; }
.kb img { display: block; }
.kb i { font-style: normal; }

/* Placement helpers. .kb-b positions; .kb-r also sizes. */
.kb-b { position: absolute; left: calc(var(--x) * var(--kb-u)); top: calc(var(--y) * var(--kb-u)); }
.kb-r { position: absolute; left: calc(var(--x) * var(--kb-u)); top: calc(var(--y) * var(--kb-u));
        width: calc(var(--w) * var(--kb-u)); height: calc(var(--h) * var(--kb-u)); }

/* --- Chrome: title and the two header glyphs ------------------------- */
.kb-title { --x: 405; --y: 89; font-weight: 800; font-size: calc(40 * var(--kb-u)); line-height: 1.5; white-space: nowrap; }
.kb-bell  { --x: 1514.887; --y: 67.313; --w: 38.382; --h: 39.238; }
.kb-kebab { --x: 1601.596; --y: 68.351; --w: 9.416;  --h: 38.233; }
.kb-bell img, .kb-kebab img { width: 100%; height: 100%; }

/* --- Sidebar ---------------------------------------------------------
   The panel's corner is elliptical in the export (rx 36.43 / ry 33.22),
   not circular, so the radius is written as the two-axis pair rather
   than rounded to one number. Same for the nav pill below.
   --------------------------------------------------------------------- */
.kb-side {
  --x: 53.000; --y: 52; --w: 320.261; --h: 773.044;
  border-radius: calc(36.4288 * var(--kb-u)) / calc(33.2185 * var(--kb-u));
  background: rgba(0, 0, 0, 0.55);
}
.kb-logo      { --x: 119.038; --y: 97.278;  --w: 39.421;  --h: 39.435; }
.kb-logo-text { --x: 170.376; --y: 101.759; --w: 140.150; --h: 31.157; }
.kb-logo img, .kb-logo-text img { width: 100%; height: 100%; }

/* Each row is one 233.017 x 56.322 box — the size of the active pill in
   the design — centred on its own label, which puts the pill at the
   artboard's exact y for Dashboard (206.609) and gives every other row
   the same relationship. Icon and label are then placed inside the row,
   so moving the active state is only a class change. */
.kb-nav-i {
  --x: 96.070;
  --w: 233.017;
  --h: 56.322;
  position: absolute;
  left: calc(var(--x) * var(--kb-u));
  top: calc((var(--label-y) - 13.252) * var(--kb-u));
  width: calc(var(--w) * var(--kb-u));
  height: calc(var(--h) * var(--kb-u));
}
.kb-nav-b {
  position: absolute;
  inset: 0;
  display: block;
  border-radius: calc(28.4836 * var(--kb-u)) / calc(28.0450 * var(--kb-u));
  transition: background-color 0.18s ease;
}
.kb-nav-b:hover { background: rgba(255, 255, 255, 0.06); }
.kb-nav-i.is-on .kb-nav-b,
.kb-nav-i.is-on .kb-nav-b:hover { background: var(--kb-green); }
.kb-nav-ic {
  position: absolute;
  left: calc(19.878 * var(--kb-u));
  top: calc(var(--ic-y) * var(--kb-u));
  width: calc(var(--ic-w) * var(--kb-u));
  height: calc(var(--ic-h) * var(--kb-u));
}
.kb-nav-t {
  position: absolute;
  left: calc(var(--t-x, 75.096) * var(--kb-u));
  top: calc(13.252 * var(--kb-u));
  font-weight: 500;
  font-size: calc(19 * var(--kb-u));
  line-height: 1.5;
  white-space: nowrap;
}
.kb-nav-i--dash     { --label-y: 219.861; --ic-y: 10.443; --ic-w: 36.016; --ic-h: 36.014; }
.kb-nav-i--subjects { --label-y: 309.313; --ic-y: 9.939;  --ic-w: 36.025; --ic-h: 39.281; }
.kb-nav-i--sections { --label-y: 399.870; --ic-y: 12.147; --ic-w: 31.846; --ic-h: 31.838; }
.kb-nav-i--groups   { --label-y: 480.487; --ic-y: 16.565; --ic-w: 40.015; --ic-h: 29.964; }
.kb-nav-i--grades   { --label-y: 566.626; --ic-y: 13.252; --ic-w: 29.063; --ic-h: 36.242; }
.kb-nav-i--calendar { --label-y: 654.974; --ic-y: 14.356; --ic-w: 32.756; --ic-h: 28.552; }
.kb-nav-i--kurby    { --label-y: 734.487; --ic-y: 16.565; --ic-w: 33.130; --ic-h: 27.609; --t-x: 73.991; }

/* --- Account widget --------------------------------------------------- */
.kb-acct   { --x: 50; --y: 850; --w: 323.574; --h: 241.852; border-radius: calc(30 * var(--kb-u)); background: rgba(0, 0, 0, 0.55); }
.kb-avatar { --x: 164.852; --y: 869.878; --w: 83.930; --h: 83.930; border-radius: 50%; object-fit: cover; }
.kb-acct-name,
.kb-acct-yr { position: absolute; left: calc(var(--x) * var(--kb-u)); top: calc(var(--y) * var(--kb-u));
              width: calc(var(--w) * var(--kb-u)); text-align: center; line-height: 1.5; }
.kb-acct-name { --x: 138.348; --y: 963.748; --w: 124; font-size: calc(20 * var(--kb-u)); }
.kb-acct-yr   { --x: 167.061; --y: 991.357; --w: 72;  font-weight: 300; font-size: calc(13 * var(--kb-u)); }

/* Two identical pills. The design's label offsets are not symmetric
   inside them, so each label is placed rather than centred. */
.kb-pill {
  --w: 113.748; --h: 39.757;
  position: absolute;
  left: calc(var(--x) * var(--kb-u));
  top: calc(var(--y) * var(--kb-u));
  width: calc(var(--w) * var(--kb-u));
  height: calc(var(--h) * var(--kb-u));
  border-radius: calc(30 * var(--kb-u));
  font-size: calc(13 * var(--kb-u));
  line-height: 1.5;
  transition: filter 0.18s ease;
}
.kb-pill:hover { filter: brightness(1.14); }
.kb-pill span { position: absolute; left: calc(var(--lx) * var(--kb-u)); top: calc(var(--ly) * var(--kb-u)); white-space: nowrap; }
.kb-pill--out { --x: 81;      --y: 1027;     --lx: 31;     --ly: 10;    background: var(--kb-red); }
.kb-pill--set { --x: 227.800; --y: 1026.696; --lx: 26.504; --ly: 8.834; background: var(--kb-grey); }

/* --- Upcoming Events -------------------------------------------------
   The card is 953 tall with its top edge 164 down the artboard, so it
   ends exactly at the artboard's bottom. The rule under the header is
   the export's own Line 1, at +57.915.
   --------------------------------------------------------------------- */
.kb-events      { --x: 405; --y: 164;     --w: 933.710; --h: 953; border-radius: calc(30 * var(--kb-u)); background: #121212; }
.kb-events-rule { --x: 405; --y: 221.915; --w: 933.710; --h: 1;   background: var(--kb-dim);
                  height: 1px; margin-top: -1px; }
.kb-events-ic   { --x: 446.444; --y: 180.876; --w: 35.349; --h: 28.079; }
.kb-events-ic img { width: 100%; height: 100%; }
.kb-events-t {
  --x: 490.326; --y: 180.876; --w: 220;
  position: absolute;
  left: calc(var(--x) * var(--kb-u));
  top: calc(var(--y) * var(--kb-u));
  width: calc(var(--w) * var(--kb-u));
  font-weight: 600;
  font-size: calc(24 * var(--kb-u));
  line-height: normal;
}
.kb-events-add { --x: 1269.230; --y: 183.854; --w: 28.036; --h: 22.832; transition: transform 0.18s ease; }
.kb-events-add img { width: 100%; height: 100%; }
.kb-events-add:hover { transform: scale(1.12); }

/* --- The feed --------------------------------------------------------
   The one place this departs from a flat transcription of the artboard.
   In Figma the day list simply overflows the frame and is cropped; here
   it is the same content at the same positions inside a scroller filling
   the card's body, so the days below the fold are reachable. At
   scroll-top every element is exactly where Figma puts it.

   data-lenis-prevent stops the page's smooth scroll swallowing the wheel
   while the pointer is over the list.
   --------------------------------------------------------------------- */
.kb-feed {
  --x: 405; --y: 221.915; --w: 933.710;
  position: absolute;
  left: calc(var(--x) * var(--kb-u));
  top: calc(var(--y) * var(--kb-u));
  width: calc(var(--w) * var(--kb-u));
  height: calc(895.085 * var(--kb-u));
  overflow-y: auto;
  overflow-x: hidden;
  scrollbar-width: none;
  overscroll-behavior: contain;
}
.kb-feed::-webkit-scrollbar { width: 0; height: 0; }
.kb-feed-in { position: relative; height: calc(1140 * var(--kb-u)); }

.kb-day,
.kb-none,
.kb-line,
.kb-card { position: absolute; left: calc(48.758 * var(--kb-u)); top: calc(var(--y) * var(--kb-u)); }

.kb-day  { font-weight: 600; font-size: calc(24 * var(--kb-u)); line-height: normal; white-space: nowrap; }
.kb-none { font-weight: 300; font-size: calc(16 * var(--kb-u)); line-height: normal; white-space: nowrap; }
/* Kept at a device pixel rather than a scaled 1 design px (0.46px
   here, which renders as a wash rather than a rule), and pulled up by
   its own height so the drawn band lands on the y Figma reports — the
   export bleeds this stroke above a zero-height box. Same for the
   header rule above. */
.kb-line { width: calc(834.976 * var(--kb-u)); height: 1px; margin-top: -1px; background: #fff; }

.kb-card {
  width: calc(834.976 * var(--kb-u));
  height: calc(98.734 * var(--kb-u));
  border-radius: calc(15 * var(--kb-u));
  background: var(--bg);
  transition: transform 0.22s cubic-bezier(0.22, 1, 0.36, 1), filter 0.22s ease;
}
.kb-card:hover { transform: translateY(calc(-2 * var(--kb-u))); filter: brightness(1.1); }
.kb-card-t { position: absolute; left: calc(var(--tx) * var(--kb-u)); top: calc(14.627 * var(--kb-u));
             font-weight: 500; font-size: calc(24 * var(--kb-u)); line-height: normal; white-space: nowrap; }
.kb-card-s { position: absolute; left: calc(var(--tx) * var(--kb-u)); top: calc(49.977 * var(--kb-u));
             font-style: italic; font-size: calc(20 * var(--kb-u)); line-height: normal; white-space: nowrap; }
.kb-card-d { position: absolute; left: calc(var(--dx) * var(--kb-u)); top: calc(14.627 * var(--kb-u));
             font-weight: 600; font-size: calc(24 * var(--kb-u)); line-height: normal; white-space: nowrap; }

/* --- Calendar --------------------------------------------------------
   Day numbers are Roboto in the design and the repo carries no Roboto,
   so the platform UI face stands in — the same call the Swiftpay status
   bar documents for Inter. They render at about 3px here.
   --------------------------------------------------------------------- */
.kb-cal { --x: 1364; --y: 175; --w: 320; --h: 156; border-radius: calc(30 * var(--kb-u)); background: #000; }
.kb-cal-month {
  --x: 1486; --y: 205; --w: 70;
  position: absolute;
  left: calc(var(--x) * var(--kb-u));
  top: calc(var(--y) * var(--kb-u));
  width: calc(var(--w) * var(--kb-u));
  text-align: center;
  font-weight: 600;
  font-size: calc(14.569 * var(--kb-u));
  line-height: normal;
}
/* The export carries 0.75 of stroke bleed on all four sides of its
   241 x 10.5 box, hence the negative inset rather than a 100% fill. */
.kb-cal-nav { --x: 1401; --y: 207; --w: 241; --h: 10.5; }
.kb-cal-nav img { position: absolute; inset: calc(-0.75 * var(--kb-u)); width: calc(242.5 * var(--kb-u)); height: calc(12 * var(--kb-u)); }

.kb-cal-week { --x: 1409; --y: 250; --w: 232; --h: 12; }
.kb-cal-week span { position: absolute; left: calc(var(--dx) * var(--kb-u)); top: 0;
  width: calc(var(--dw) * var(--kb-u)); text-align: center;
  font-size: calc(9.712 * var(--kb-u)); line-height: normal; }

.kb-cal-days { --x: 1402; --y: 274; --w: 246; --h: 24; }
.kb-cal-d {
  position: absolute;
  left: calc(var(--dx) * var(--kb-u));
  top: 0;
  width: calc(24 * var(--kb-u));
  height: calc(24 * var(--kb-u));
  border-radius: 50%;
  background: var(--kb-grey);
  transition: background-color 0.18s ease;
}
.kb-cal-d span { position: absolute; left: calc(5 * var(--kb-u)); top: calc(6 * var(--kb-u));
  width: calc(12 * var(--kb-u)); text-align: center; line-height: normal;
  font-family: system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;
  font-size: calc(10 * var(--kb-u)); }
.kb-cal-d:hover { background: #8b8b8b; }

/* The selected column: a white pill behind the day, the day itself green
   with dark type, and the weekday letter above darkened to match. */
.kb-cal-sel {
  --y: 241; --w: 32; --h: 61;
  position: absolute;
  left: calc(var(--x) * var(--kb-u));
  top: calc(var(--y) * var(--kb-u));
  width: calc(var(--w) * var(--kb-u));
  height: calc(var(--h) * var(--kb-u));
  border-radius: calc(100 * var(--kb-u));
  background: #fff;
  transition: left 0.28s cubic-bezier(0.22, 1, 0.36, 1);
}
.kb-cal-d.is-on,
.kb-cal-d.is-on:hover { background: #8de77d; }
.kb-cal-d.is-on span { color: #222; }
.kb-cal-week span.is-on { color: #121212; }

/* --- To-Do list ------------------------------------------------------- */
.kb-todo { --x: 1364; --y: 352.965; --w: 320.261; --h: 552.174; border-radius: calc(30 * var(--kb-u)); background: #0f0f0f; }
.kb-todo-ic { --x: 1397.130; --y: 377.261; --w: 30.922; --h: 30.922; }
.kb-todo-ic img { width: 100%; height: 100%; }
.kb-todo-t {
  --x: 1435.783; --y: 377.261;
  position: absolute;
  left: calc(var(--x) * var(--kb-u));
  top: calc(var(--y) * var(--kb-u));
  font-weight: 500;
  font-size: calc(24 * var(--kb-u));
  line-height: normal;
  white-space: nowrap;
}
.kb-todo-add { --x: 1633.461; --y: 377.261; --w: 25.4; --h: 25.4; transition: transform 0.18s ease; }
.kb-todo-add img { width: 100%; height: 100%; }
.kb-todo-add:hover { transform: scale(1.12); }
.kb-todo-div { --x: 1424.739; --y: 520.076; --w: 228.6; --h: 1.5; background: var(--kb-rule); border-radius: calc(0.75 * var(--kb-u)); }

/* A task is the 18px box plus a text column 33 to its right; a sub-task
   is the same box with a single label 30 across. Both are buttons, so
   the checkbox is reachable by keyboard as well as pointer. */
.kb-task { position: absolute; left: calc(var(--x) * var(--kb-u)); top: calc(var(--y) * var(--kb-u)); text-align: left; }
.kb-box {
  position: absolute;
  left: 0;
  top: 0;
  width: calc(18 * var(--kb-u));
  height: calc(18 * var(--kb-u));
  border: calc(2 * var(--kb-u)) solid var(--kb-dim);
  border-radius: calc(4 * var(--kb-u));
  box-sizing: border-box;
  transition: border-color 0.18s ease;
}
.kb-task:hover .kb-box { border-color: #9a9a9a; }
/* The checked mark is the design's own 22 x 22 export, whose 2px stroke
   sits outside the 18px box — so it is inset by that overhang rather
   than sized to the box. */
.kb-box img { position: absolute; inset: calc(-2 * var(--kb-u)); width: calc(22 * var(--kb-u)); height: calc(22 * var(--kb-u)); opacity: 0; transition: opacity 0.16s ease; }
.kb-task.is-done .kb-box { border-color: transparent; }
.kb-task.is-done .kb-box img { opacity: 1; }

.kb-task-c { position: absolute; left: calc(33 * var(--kb-u)); top: 0; width: max-content; }
.kb-task-h { font-size: calc(14 * var(--kb-u)); line-height: normal; white-space: nowrap; }
.kb-task.is-done .kb-task-h { text-decoration: line-through; text-decoration-skip-ink: none; }
.kb-task-m {
  position: absolute;
  left: 0;
  top: calc(23 * var(--kb-u));
  display: flex;
  align-items: center;
  font-size: calc(14 * var(--kb-u));
  line-height: normal;
  white-space: nowrap;
}
.kb-task-m b { font-weight: 400; color: var(--kb-dim); }
.kb-task.is-done .kb-task-m b { color: var(--kb-grey); }
.kb-task-m em { display: block; width: calc(1.5 * var(--kb-u)); height: calc(14.5 * var(--kb-u));
                margin: 0 calc(6.5 * var(--kb-u)); border-radius: calc(0.75 * var(--kb-u)); background: var(--kb-rule); }
.kb-task-m u { text-decoration: none; color: var(--kb-red); }

.kb-sub .kb-task-c { left: calc(30 * var(--kb-u)); color: var(--kb-sub); }

.kb-task--physics   { --x: 1389.400; --y: 466.713; }
.kb-task--astronomy { --x: 1426.948; --y: 536.287; }
.kb-task--work      { --x: 1426.948; --y: 572.400; }
.kb-task--chemistry { --x: 1389.400; --y: 629.052; }
.kb-task--english   { --x: 1389.400; --y: 706.357; }
/* The artboard carries a second, unattached checked box at 1377 / 670 —
   off the column the other boxes share, and with nothing beside it. It is
   a leftover in the file rather than a list item, and it read as a bug on
   screen, so it is deliberately not reproduced. */

/* --- KurbyAi widget --------------------------------------------------- */
.kb-kurby      { --x: 1362; --y: 921.704; --w: 323.574; --h: 241.852; border-radius: calc(30 * var(--kb-u)); background: rgba(0, 0, 0, 0.55); }
.kb-kurby-face { --x: 1385.192; --y: 939.374; --w: 44.174; --h: 36.443; }
.kb-kurby-face img { width: 100%; height: 100%; }
.kb-kurby-t {
  --x: 1444.826; --y: 940.478;
  position: absolute;
  left: calc(var(--x) * var(--kb-u));
  top: calc(var(--y) * var(--kb-u));
  font-weight: 700;
  font-size: calc(19 * var(--kb-u));
  line-height: 1.5;
  white-space: nowrap;
}
.kb-kurby-c {
  --x: 1451.826; --y: 986.861; --w: 139;
  position: absolute;
  left: calc(var(--x) * var(--kb-u));
  top: calc(var(--y) * var(--kb-u));
  width: calc(var(--w) * var(--kb-u));
  text-align: center;
  font-weight: 500;
  font-size: calc(19 * var(--kb-u));
  line-height: 1.5;
}
.kb-kurby-c span { display: block; }
.kb-kurby-c span:first-child { margin-bottom: calc(3.718 * var(--kb-u)); }
.kb-kurby-ask { --x: 1455.870; --y: 1073; --lx: 12.147; --ly: 9.939; background: var(--kb-grey); font-weight: 500; font-size: calc(11 * var(--kb-u)); }

/* --- Devices: narrow viewports --------------------------------------- */

@media (max-width: 900px) {
  .rb-dev-stage {
    flex-direction: column;
    gap: calc(56 * var(--rb-u));
    --rb-u: clamp(0.62px, 0.16vw, 1.2px);
  }
  .rb-devices { padding-top: var(--rb-section-y); }
  .rb-approach-stage { max-width: 380px; }
}

/* ---------------------------------------------------------------------
   Work page — bento grid.
   .work-collection is the standalone Work listing page's wrapper (see
   src/legacy/sporsho_work.html); it shares .project-card/.project-media
   with the home page's frozen .work-showcase preview strip, so every
   selector below is scoped under .work-collection and the frozen
   section (never touched) is unaffected. This replaces the shared
   file's uniform 4-column staggered grid with a hand-composed
   asymmetric mosaic — the Work page holds exactly seven fixed projects
   (project-data.js), not an open-ended list, so a bespoke per-tile size
   reads as a real bento box instead of a formula repeating over an
   unknown count.

   grid-auto-flow: dense repacks around any project a filter hides
   (display: none via .is-hidden) instead of leaving a hole where it
   was; a residual empty cell is just the section's own --ink
   background, invisible against it.
   --------------------------------------------------------------------- */

@media (min-width: 1025px) {
  .work-collection .project-collection {
    display: grid;
    grid-template-columns: repeat(6, minmax(0, 1fr));
    grid-auto-rows: clamp(120px, 11vw, 190px);
    grid-auto-flow: dense;
    column-gap: clamp(18px, 1.6vw, 28px);
    row-gap: clamp(18px, 1.6vw, 28px);
  }

  .work-collection .project-collection .project-card:nth-child(n) { margin-top: 0; }

  .work-collection .project-collection .project-card:not(.is-hidden) {
    display: flex;
    flex-direction: column;
    height: 100%;
  }

  .work-collection .project-collection .project-media {
    flex: 1 1 auto;
    min-height: 0;
    aspect-ratio: auto;
  }

  /* BWT Alpine — full-bleed hero */
  .work-collection .project-collection .project-card:nth-child(1) { grid-column: span 6; grid-row: span 2; }
  /* Swiftpay — tall half */
  .work-collection .project-collection .project-card:nth-child(2) { grid-column: span 3; grid-row: span 2; }
  /* Kursbit / Snova — quarter tiles stacked beside it */
  .work-collection .project-collection .project-card:nth-child(3) { grid-column: span 3; grid-row: span 1; }
  .work-collection .project-collection .project-card:nth-child(4) { grid-column: span 3; grid-row: span 1; }
  /* Flamongo's — tall third */
  .work-collection .project-collection .project-card:nth-child(5) { grid-column: span 2; grid-row: span 2; }
  /* Agonvista — small square */
  .work-collection .project-collection .project-card:nth-child(6) { grid-column: span 2; grid-row: span 1; }
  /* Castles — wide banner closing the mosaic */
  .work-collection .project-collection .project-card:nth-child(7) { grid-column: span 4; grid-row: span 1; }
}

/* Castles' source thumbnail (P7 Thumbnail.png) is an extreme 1836x218
   wordmark banner with the logo sitting in the left ~20%; object-fit:cover
   at any of this grid's tile shapes crops straight past it into the flat
   navy background, reading as a missing/broken image. Not breakpoint-
   specific — applies everywhere this card renders. */
.work-collection .project-collection .project-card:nth-child(7) .project-media img {
  object-position: left center;
}

@media (min-width: 701px) and (max-width: 1024px) {
  .work-collection .project-collection {
    grid-template-columns: repeat(4, minmax(0, 1fr));
    grid-auto-rows: clamp(140px, 18vw, 220px);
    grid-auto-flow: dense;
    row-gap: clamp(18px, 2vw, 28px);
  }

  .work-collection .project-collection .project-card:not(.is-hidden) {
    display: flex;
    flex-direction: column;
    height: 100%;
  }

  .work-collection .project-collection .project-media {
    flex: 1 1 auto;
    min-height: 0;
    aspect-ratio: auto;
  }

  .work-collection .project-collection .project-card:nth-child(1) { grid-column: span 4; grid-row: span 2; }
  .work-collection .project-collection .project-card:nth-child(2),
  .work-collection .project-collection .project-card:nth-child(5) { grid-column: span 2; grid-row: span 2; }
  .work-collection .project-collection .project-card:nth-child(3),
  .work-collection .project-collection .project-card:nth-child(4),
  .work-collection .project-collection .project-card:nth-child(6),
  .work-collection .project-collection .project-card:nth-child(7) { grid-column: span 2; grid-row: span 1; }
}

/* ---------------------------------------------------------------------
   Reduced motion — content shown immediately, layout unchanged.
   --------------------------------------------------------------------- */

@media (prefers-reduced-motion: reduce) {
  .rb-reveal { opacity: 1 !important; transform: none !important; }
  .rb-media-reveal { clip-path: none !important; }
  .rb-media-reveal > img,
  .rb-media-reveal > video { transform: none !important; }
  .rb-line-inner { transform: none !important; }
  .rb-cursor,
  .rb-range-figure { display: none !important; }
  /* No preview runs here, so the cursor must come back. */
  [data-rb-range="expertise"] { cursor: auto !important; }
  .rb-link-roll span,
  .rb-link-line,
  .rb-link-line-2 { transition: none !important; }
  .rb-footer { transform: none !important; }

  /* Kursbit’s state changes land instantly; the interface stays live. */
  .kb-nav-b, .kb-pill, .kb-card, .kb-cal-d, .kb-cal-sel,
  .kb-box, .kb-box img, .kb-events-add, .kb-todo-add { transition: none !important; }

  /* Devices land at their resting state and stay interactive; only the
     scroll-driven entrance is dropped. initDevices() skips its timeline
     under the same query and clears `inert` immediately. */
  .rb-dev { transform: none !important; opacity: 1 !important; }
}

/* --- Hero video dock -------------------------------------------------
   The body-level fixed host heroDock (portfolio-experience.js) re-parents
   the hero's <video> into so it can fly out of the hero and land in the
   Alpine card in Selected Work. Empty and display:none until the move
   starts, so it costs nothing on every other page and on the whole first
   screen of this one.

   z-index sits above .hero-stage (4500) so the video is on top of the
   hero while the hero is still fading out behind it, and below .site-nav
   (9000) so the nav is never covered. transform-origin: 0 0 is what lets
   heroDock treat the transform as a plain "map this box onto that rect"
   — with the default centre origin every translate would have to carry a
   half-size correction that changes with the scale.

   The video inside keeps its own .hero-canvas-video rule (which is what
   zooms past the clip's baked-in letterbox bars), so the host's
   overflow: hidden is doing the same job the hero's .hero-canvas-wrap
   does for it at rest. ------------------------------------------------ */
.rb-hero-dock {
  position: fixed;
  inset: 0;
  z-index: 4600;
  display: none;
  overflow: hidden;
  background: var(--ink);
  transform-origin: 0 0;
  pointer-events: none;
  will-change: transform, clip-path;
}

.rb-hero-dock--live { display: block; }

/* --- Intro loader percentage -----------------------------------------
   The count in the loader's bottom-left corner. It carries the SAME
   bottom-to-top colour fill as the mark above it, driven by the same
   clip-path string from the same timeline (heroIntro in
   portfolio-experience.js) — so the cream climbs the digits and the logo
   at one height, and the number reads as part of the fill rather than a
   caption beside it. __base is the unfilled state at the same 16% cream
   .intro-mark-base uses; __fill is the solid copy stacked exactly on top
   and clipped.

   .rb-* and in this file rather than alongside the loader's own .intro-*
   rules in portfolio-experience.css, per the namespacing rule — the
   element is new, so it lives here.

   tabular-nums is load-bearing: proportional digits make a counter twitch
   sideways on every tick, which is the one thing a number pinned to a
   corner must not do. z-index clears .intro-pixels (2) so the count stays
   legible while the grid assembles over it. ------------------------- */
.rb-intro-count {
  position: absolute;
  left: 18px;
  bottom: 14px;
  z-index: 3;
  font: 500 40px/1 'Neue Montreal', Arial, sans-serif;
  font-variant-numeric: tabular-nums;
  letter-spacing: -0.02em;
  pointer-events: none;
}

.rb-intro-count__base { display: block; color: rgba(255, 253, 214, 0.16); }

.rb-intro-count__fill {
  position: absolute;
  inset: 0;
  color: var(--cream);
  clip-path: inset(100% 0 0 0);
  /* The clip is rewritten every frame for ~2.75s; without this the
     browser re-rasterises the text on each one. */
  will-change: clip-path;
}
