/* ============================================================
   Nugraha Putra — putranugra.com

   Type and spacing mirror the Figma variables 1:1. Colour no longer
   does: the light ramp moved from zinc to stone here first, and Figma
   still holds the zinc values. Reconcile the two before reading any
   colour off a frame.
   ============================================================ */

:root {
  /* Colour — Tailwind stone, not zinc.

     Zinc carries a slight blue cast. On a page that is mostly empty
     surface it is that cast, not the brightness, that reads as harsh:
     blue sits at the end of the spectrum the eye finds sharpest, and a
     700px column of it in a dark room is a lot of it. Stone is the same
     ramp one hue over, warm instead of cool, so every step keeps its
     job and only the temperature moves.

     The top of the ramp is the one custom value. Stone starts at
     stone-50, which is where --bg-subtle already sits, so the page
     needs a step above it that is still not pure white. #fdfcfa is
     that step: 2.6% less light than #ffffff, and warm enough that the
     cast is gone. Most of the relief here comes from the hue, not the
     luminance — for the brightness itself the answer is dark mode,
     which already follows the visitor's OS on its own. */
  --base-white:     #fdfcfa;   /* warm off-white, one step above stone-50 */
  --text-primary:   #1c1917;   /* stone-900 */
  --text-secondary: #57534e;   /* stone-600 */
  --border-default: #e7e5e4;   /* stone-200 */
  --border-strong:  #d6d3d1;   /* stone-300 */
  --bg-muted:       #f5f5f4;   /* stone-100 */
  --bg-subtle:      #fafaf9;   /* stone-50  */

  /* The coupon is a printed object, not a surface of the page, so it
     keeps its own colour in both themes — the same way a real voucher
     does not change colour when the lights go out. That also solves
     the problem it had while it followed the palette: on ink the paper
     and the page ground were both #111 and the ticket was an outline
     floating in nothing.

     Fixed paper means fixed ink. If the type kept following the
     palette it would turn near-white on amber in dark mode, which is
     unreadable — the same reasoning that pins --on-accent above. These
     two are the paper's own primary and secondary, holding 12.3:1 and
     5.3:1 on it, and they are the only colours on the site that never
     move. */
  --coupon-paper:    #fcd34d;
  --coupon-ink:      #1c1917;
  --coupon-ink-soft: #57534e;

  --success:    #22c55e;
  --success-bg: #f0fdf4;
  --success-fg: #14532d;
  --danger:     #ef4444;
  --danger-bg:  #fef2f2;
  --danger-fg:  #991b1b;

  /* the one saturated colour in a palette that is otherwise all neutrals */
  --star:       #f59e0b;

  /* the document icon in the writings list is the one blue on the site */
  --doc-fill:   #eff6ff;
  --doc-stroke: #3b82f6;

  /* Lifted from the favicon, and the only warm surface on the site. Used
     by the two controls that carry state — the theme switch and the tab
     plate — so "on" looks the same wherever it appears. Deliberately not
     redefined for dark: a yellow that dims is a yellow that stopped
     meaning "on". */
  --accent-gradient: linear-gradient(164deg, #ffcc00 16%, #e3ff45 83%);

  /* label ink for that gradient. Fixed, because the plate under it does
     not follow the palette either — stone-600 holds ~5:1 on the darkest
     end of the ramp in both themes. */
  --on-accent: #57534e;

  /* type — from Figma text styles. Figma stores tracking as a percentage of
     the font size, so these stay in em rather than px. */
  --font-sans: "Geist", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;

  --heading-xl-size: 24px;
  --heading-xl-line: 31.2px;
  --heading-xl-track: -0.003em;

  --heading-lg-size: 20px;
  --heading-lg-line: 28px;
  --heading-lg-track: -0.002em;

  --heading-md-size: 17px;
  --heading-md-line: 23.8px;
  --heading-md-track: -0.001em;

  --body-lg-size: 16px;
  --body-lg-line: 25.6px;

  --heading-sm-size: 15px;
  --heading-sm-line: 22.5px;

  --body-md-size: 14px;
  --body-md-line: 22px;

  --body-sm-size: 13px;
  --body-sm-line: 19.5px;

  --label-size: 12px;
  --label-line: 18px;

  --caption-size: 11px;
  --caption-line: 15.4px;
  --caption-track: 0.001em;

  /* layout */
  --radius-card: 10px;

  --shell-width: 800px;
  --shell-pad-y: 100px;
  --shell-pad-x: 50px;
  --stack-gap: 80px;
  --block-gap: 24px;
  --list-gap: 12px;

  /* hover states that used to be written as literals inline. They are
     tokens now because dark mode has to move them in the opposite
     direction: a border that lightens on white must darken on ink. */
  --border-hover: #d6d3d1;   /* stone-300 */
  --btn-hover:    #44403c;   /* stone-700 */

  /* article covers are drawn on cream. On a dark page that reads as a
     lightbox, so dark mode knocks them back a little. */
  --cover-brightness: 1;

  /* motion */
  --ease-out: cubic-bezier(0.16, 1, 0.3, 1);
  --dur-micro: 150ms;
  --dur-small: 250ms;
  --dur-plate: 320ms;

  color-scheme: light;
}

/* ── DARK ──────────────────────────────────────────────────────
   Dark does not mirror light. It never did: back when light was zinc,
   dark dropped the blue cast because a cast that is invisible on white
   turns clinical on ink. Light is stone now, and dark stays neutral for
   the mirror-image reason — warmth that reads as paper under a lamp
   reads as nicotine on a dark ground. Both themes are quiet greys; they
   just get there from opposite sides.

   The page also sits at #111 rather than near-black. Two thirds of a
   stop of headroom is the difference between a background that reads
   as "unlit" and one that reads as a hole punched in the screen, and
   it softens every contrast ratio on the page along with it.

   Two selectors carry the same values on purpose. The media query
   honours the visitor's OS setting on a first visit with no JS, and
   the attribute lets the footer switch override it in either
   direction afterwards. :not([data-theme="light"]) is what stops the
   OS from overruling someone who explicitly asked for light.

   Note what does NOT need touching: every ink-coloured chip on the
   site is written `background: var(--text-primary); color:
   var(--base-white)`, so swapping those two tokens flips the chip to
   a light plate with dark type on its own.
   ────────────────────────────────────────────────────────────── */

@media (prefers-color-scheme: dark) {
  :root:not([data-theme="light"]) {
    --base-white:     #111111;
    --text-primary:   #ededed;
    --text-secondary: #a3a3a3;
    --border-default: #262626;
    --border-strong:  #404040;
    --bg-muted:       #1a1a1a;
    --bg-subtle:      #171717;

    --border-hover: #525252;
    --btn-hover:    #d4d4d4;

    --success:    #4ade80;
    --success-bg: #0f2a1a;
    --success-fg: #86efac;
    --danger:     #f87171;
    --danger-bg:  #2e1416;
    --danger-fg:  #fca5a5;

    --star:       #fbbf24;

    --doc-fill:   #172554;
    --doc-stroke: #60a5fa;

    --cover-brightness: 0.88;

    color-scheme: dark;
  }
}

:root[data-theme="dark"] {
  --base-white:     #111111;
  --text-primary:   #ededed;
  --text-secondary: #a3a3a3;
  --border-default: #262626;
  --border-strong:  #404040;
  --bg-muted:       #1a1a1a;
  --bg-subtle:      #171717;

  --border-hover: #525252;
  --btn-hover:    #d4d4d4;

  --success:    #4ade80;
  --success-bg: #0f2a1a;
  --success-fg: #86efac;
  --danger:     #f87171;
  --danger-bg:  #2e1416;
  --danger-fg:  #fca5a5;

  --star:       #fbbf24;

  --doc-fill:   #172554;
  --doc-stroke: #60a5fa;

  --cover-brightness: 0.88;

  color-scheme: dark;
}

*,
*::before,
*::after { box-sizing: border-box; margin: 0; padding: 0; }

html { -webkit-text-size-adjust: 100%; }

body {
  background: var(--base-white);
  color: var(--text-secondary);
  font-family: var(--font-sans);
  font-size: var(--body-lg-size);
  line-height: var(--body-lg-line);
  font-weight: 400;
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
}

img { display: block; max-width: 100%; }

.skip-link {
  position: absolute;
  left: -9999px;
  top: 0;
  z-index: 100;
  background: var(--text-primary);
  color: var(--base-white);
  font-size: var(--body-sm-size);
  line-height: var(--body-sm-line);
  padding: 8px 14px;
}

.skip-link:focus { left: 8px; top: 8px; }

/* ── SHELL ─────────────────────────────────────────────────── */

.page {
  display: flex;
  justify-content: center;
  min-height: 100vh;
}

.shell {
  width: var(--shell-width);
  max-width: 100%;
  min-height: 100vh;
  /* no frame — the 800px box only exists to centre a 700px column */
  padding: var(--shell-pad-y) var(--shell-pad-x);
  background: var(--base-white);
}

.content {
  display: flex;
  flex-direction: column;
  gap: var(--stack-gap);
  width: 100%;
}

/* ── HEADING ───────────────────────────────────────────────── */

.heading {
  display: flex;
  flex-direction: column;
  gap: var(--block-gap);
}

.identity { display: flex; flex-direction: column; gap: 4px; }

.identity__name {
  /* shrink to the text so the hover target is the name, not the whole column */
  align-self: flex-start;
  cursor: pointer;
  font-size: var(--heading-md-size);
  line-height: var(--heading-md-line);
  letter-spacing: var(--heading-md-track);
  font-weight: 500;
  color: var(--text-primary);
}

/* the surname types in on hover — `pre` keeps the leading space, which a
   normal inline box would collapse until the next letter arrives */
[data-type-slot] { white-space: pre; }

.identity__caret {
  display: inline-block;
  width: 1px;
  height: 1em;
  margin-left: 1px;
  vertical-align: -0.12em;
  background: currentColor;
  opacity: 0;
}

@media (hover: hover) and (pointer: fine) {
  .identity__name:hover .identity__caret {
    opacity: 1;
    animation: caret-blink 1s steps(1, end) infinite;
  }
}

@keyframes caret-blink { 50% { opacity: 0; } }

/* set as body copy, not as a second heading line */
.identity__role {
  font-size: var(--body-lg-size);
  line-height: var(--body-lg-line);
  font-weight: 400;
  color: var(--text-secondary);
}

/* the 24px above comes from .heading; these three sit 12px apart */
.heading__body {
  display: flex;
  flex-direction: column;
  gap: 12px;
  max-width: 70ch;
}

/* company names lifted out of the body ink. Figma draws the linked one and
   the unlinked one identically, so the only thing separating them is the
   underline on hover — which is enough, and keeps a sentence of prose from
   turning into a row of buttons. */
.emph {
  font-weight: 500;
  color: var(--text-primary);
}

a.emph {
  color: var(--text-primary);
  text-decoration: none;
}

@media (hover: hover) and (pointer: fine) {
  a.emph:hover {
    text-decoration: underline;
    text-decoration-thickness: 1px;
    text-underline-offset: 3px;
  }
}

a.emph:focus-visible {
  outline: 1px solid var(--text-primary);
  outline-offset: 2px;
  border-radius: 2px;
}

/* ── SOCIAL ROW ────────────────────────────────────────────── */

.social {
  display: flex;
  align-items: center;
  gap: 24px;
  list-style: none;
}

.social__link {
  /* for the hit area below */
  position: relative;
  display: block;
  color: var(--text-secondary);
  transition: color var(--dur-micro) var(--ease-out);
}

/* A 16px icon is a 16px target, which is half of what a thumb needs. This
   grows the tappable area to 40px without moving anything: -12px on each
   side exactly meets the 24px gap, so neighbouring targets touch but never
   overlap, and no icon can be hit by aiming at the one beside it. */
.social__link::after {
  content: "";
  position: absolute;
  inset: -12px;
}

.social__icon { display: block; }

@media (hover: hover) and (pointer: fine) {
  .social__link:hover { color: var(--text-primary); }
}

.social__link:focus-visible {
  outline: 1px solid var(--text-primary);
  outline-offset: 6px;
  border-radius: 2px;
}

/* ── SECTIONS ──────────────────────────────────────────────── */

.section {
  display: flex;
  flex-direction: column;
  gap: var(--block-gap);
}

.section__head { display: flex; flex-direction: column; gap: 8px; }

/* set in body ink at ExtraLight, matching the pull quotes on the case study
   pages — the eyebrow labels the section rather than shouting it */
.eyebrow {
  font-size: var(--body-sm-size);
  line-height: var(--body-sm-line);
  font-weight: 200;
  color: var(--text-secondary);
}

/* ── TABS ──────────────────────────────────────────────────────
   Project and Writing over one panel.

   The active state is a single plate that travels, not a background
   that swaps. Watching one object cross the gap tells you the two
   pills belong to the same control; watching one shape blink out
   while another blinks in tells you nothing. The plate is a sibling
   of the buttons rather than their background, which is what lets it
   move at all — and it moves on transform, so the row never reflows.
   ────────────────────────────────────────────────────────────── */

/* wider than the 24px the other sections use: the pills are a control,
   not a heading, and they need air under them to stop reading as part
   of the panel they switch */
.section--tabs { gap: 40px; }

/* The remembered tab is written onto <html> in the <head>, before the
   first paint, the same way the theme is — otherwise the page paints
   Experiment and swaps to Writing once main.js runs. Every article sits
   behind Writing, so that swap would fire on the way back from every
   single one.

   The markup ships with Experiment up, so only the other two need
   stating. And these only hold until main.js has taken over: after that
   the buttons' own aria-selected governs, and leaving these live would
   fight the swap animation by revealing the incoming panel too early. */
:root[data-tab="project"]:not(.tabs-ready) #panel-experiment { display: none; }
:root[data-tab="project"]:not(.tabs-ready) #panel-project { display: block; }
:root[data-tab="project"]:not(.tabs-ready) #tab-experiment { color: var(--text-secondary); }
:root[data-tab="project"]:not(.tabs-ready) #tab-project { color: var(--on-accent); }

:root[data-tab="writing"]:not(.tabs-ready) #panel-experiment { display: none; }
:root[data-tab="writing"]:not(.tabs-ready) #panel-writing { display: block; }
:root[data-tab="writing"]:not(.tabs-ready) #tab-experiment { color: var(--text-secondary); }
:root[data-tab="writing"]:not(.tabs-ready) #tab-writing { color: var(--on-accent); }

/* one pill (100px) plus the gap (4px), counted from the left of the row */
:root[data-tab="project"]:not(.tabs-ready) .tabs__thumb { --thumb-x: 104px; }
:root[data-tab="writing"]:not(.tabs-ready) .tabs__thumb { --thumb-x: 208px; }

.tabs__list {
  position: relative;
  align-self: flex-start;
  display: flex;
  gap: 4px;
}

.tabs__thumb {
  position: absolute;
  top: 0;
  left: 0;
  width: var(--thumb-w, 100px);
  height: 100%;
  /* half the height, so the ends stay perfectly round whatever the tab
     is wide — a percentage would distort them at any other width */
  border-radius: 20px;
  background: var(--accent-gradient);
  /* room for the gradient to travel inside the pill. At 100% there is
     nothing to move — the gradient would exactly fill the box. The
     higher this is, the narrower the slice on show, so the colour under
     any one point changes more as the light sweeps past. */
  background-size: 240% 240%;
  /* keeps the drifting layer inside the pill's ends */
  overflow: hidden;
  transform: translate3d(var(--thumb-x, 0px), 0, 0);
  transition: transform var(--dur-plate) var(--ease-out);
  animation: thumb-breathe 6s ease-in-out infinite;
}

/* Second layer: two soft blooms wandering over the base gradient, one
   warm and one pale. They exist for the two things a single sweeping
   gradient cannot do.

   Colour: the base only ever travels between its gold end and its lime
   end. The amber bloom deepens whatever it crosses and the cream one
   lifts it, so the eye reads a wider range than the two stops actually
   contain — without touching --accent-gradient, which has to keep
   matching the favicon and the theme switch.

   Movement: the two blooms are driven by one animation but travel
   opposite diagonals, and that runs at 8.5s against the base's 6s.
   Nothing divides evenly, so the three movements keep sliding out of
   phase and the arrangement does not repeat for about a minute and a
   half. That is the difference between something flowing and a loop. */
.tabs__thumb::before {
  content: "";
  position: absolute;
  inset: 0;
  background-image:
    radial-gradient(circle, rgba(255, 176, 0, 0.5) 0%, rgba(255, 176, 0, 0) 70%),
    radial-gradient(circle, rgba(255, 252, 214, 0.55) 0%, rgba(255, 252, 214, 0) 70%);
  background-size: 130% 260%, 110% 220%;
  background-repeat: no-repeat;
  animation: thumb-drift 8.5s ease-in-out infinite;
}

/* Third layer: a sheen that turns rather than travels.

   Two blooms sliding around already reads as movement, but it is all the
   same kind of movement. Rotation is a different axis entirely, and the
   three together stop resolving into anything the eye can follow.

   This is the one place geometry is animated, and the reason it is safe
   here is the reason it was not on the pill itself: the layer is far
   larger than the box and clipped by it, so no edge of it is ever on
   screen. Nothing to re-antialias, nothing to shimmer. 140% by 350% of a
   100x40 pill is a 140px square, comfortably past the 108px diagonal it
   has to cover at every angle. 19s and not a round number: 17 would have
   been exactly twice the bloom cycle, and the two would have realigned
   every 17 seconds — the repeat this whole layer exists to break.

   Every stop is lighter than the base, never darker, so the layer can
   only lift the label's contrast — it can never eat into it.

   `linear`, because a rotation that speeds up and slows down reads as a
   stutter, which is exactly what we spent three attempts removing. */
.tabs__thumb::after {
  content: "";
  position: absolute;
  top: 50%;
  left: 50%;
  width: 140%;
  height: 350%;
  background: conic-gradient(
    from 0deg,
    rgba(255, 255, 255, 0) 0%,
    rgba(255, 255, 255, 0.3) 22%,
    rgba(255, 244, 186, 0.24) 45%,
    rgba(255, 255, 255, 0) 68%,
    rgba(255, 255, 255, 0) 100%
  );
  animation: thumb-turn 19s linear infinite;
}

/* Two things on one 6s cycle: the light drifts along the gradient's own
   axis, and it swells 4% at the top of the breath. Either alone reads as
   a mistake — a gradient sliding for no reason, or a pill that flickers.
   Together they read as something lit rather than something animating.

   ease-in-out because a breath is symmetric: it slows at both ends. The
   site's usual ease-out curve would make it lunge and settle, which is
   the shape of a reaction, not of breathing.

   The reduced-motion block at the end of this file stops it outright. */
/* The light sweeps almost the full width of the gradient, so the plate
   moves between its gold end and its lime end rather than just shimmering
   in place. Brightness and saturation swell together at the top of the
   pass: colour alone reads as a slider being dragged, the swell is what
   makes it read as a breath.

   Nothing here touches geometry. An earlier version wobbled the shape
   too, and at this size the sub-pixel edges shimmered instead of moving.
   Paint is what animates well on a small crisp box; shape is not.

   Three keyframes with the middle one at 50% is the most a loop should
   have: CSS eases between each pair, so extra stops mean extra pauses. */
@keyframes thumb-breathe {
  0%, 100% {
    background-position: 10% 16%;
    filter: brightness(1) saturate(1);
  }
  50% {
    background-position: 90% 84%;
    filter: brightness(1.06) saturate(1.08);
  }
}

/* One animation, two blooms, opposite diagonals — they cross near the
   middle of the cycle and separate again. `background-position` takes a
   value per layer, which is why both can be driven from a single
   timeline while still moving independently of each other. */
@keyframes thumb-drift {
  0%, 100% { background-position: 6% 18%, 90% 80%; }
  50%      { background-position: 78% 86%, 12% 20%; }
}

/* the translate is what centres the oversized layer; the rotation is the
   only part that moves, and it carries the translate with it because a
   transform list is applied as one */
@keyframes thumb-turn {
  from { transform: translate(-50%, -50%) rotate(0deg); }
  to   { transform: translate(-50%, -50%) rotate(360deg); }
}

.tab {
  /* above the plate, and its own stacking context so the label never
     disappears under the thumb mid-travel */
  position: relative;
  z-index: 1;
  width: 100px;
  height: 40px;
  padding: 12px 8px;
  background: none;
  border: 0;
  border-radius: 20px;
  font-family: inherit;
  font-size: var(--label-size);
  line-height: var(--label-line);
  font-weight: 500;
  color: var(--text-secondary);
  text-align: center;
  cursor: pointer;
  /* slower than the plate on purpose: the ink catches up to the plate
     rather than racing it there */
  transition: color var(--dur-plate) var(--ease-out);
}

.tab[aria-selected="true"] { color: var(--on-accent); }

@media (hover: hover) and (pointer: fine) {
  .tab[aria-selected="false"]:hover { color: var(--text-primary); }
}

.tab:focus-visible {
  outline: 1px solid var(--text-primary);
  outline-offset: 3px;
}

/* ── TAB PANELS ────────────────────────────────────────────── */

.tabs__panel[hidden] { display: none; }

/* One line naming what the panel holds, sitting inside the panel rather
   than above the pills — so it changes with the tab. Body/lg secondary,
   the same setting as the role line under the name.

   The 24px is a margin rather than a gap on the panel, and deliberately
   so: the pre-paint rules further up address the panels by id, which
   outranks any `display` this class could set. A panel painted as a
   block before main.js runs would drop a flex gap and land the subtitle
   flush against the work for one frame. A margin cannot be dropped. */
.section__sub {
  margin-bottom: var(--block-gap);
  font-size: var(--body-lg-size);
  line-height: var(--body-lg-line);
  color: var(--text-secondary);
}

/* The panels are wildly different heights, so nothing here animates
   height — the swap happens while the panel is transparent, which is
   the one moment the jump costs nothing to look at. What does animate
   is the arrival: children rise in sequence, so the eye is given an
   order to read them in instead of a whole block landing at once. */
.tabs__panel.is-leaving {
  animation: tab-panel-out 110ms cubic-bezier(0.55, 0, 1, 0.45) both;
}

.tabs__panel.is-entering [data-stagger] > * {
  animation: tab-panel-in var(--dur-plate) var(--ease-out) both;
  animation-delay: calc(var(--i, 0) * 55ms);
}

/* The subtitle is the first thing to arrive — it says what the panel is,
   and the work rises in behind it. One element holding still while all
   of its neighbours move reads as unfinished, so it joins the sequence
   rather than simply appearing. */
.tabs__panel.is-entering > .section__sub {
  animation: tab-panel-in var(--dur-plate) var(--ease-out) both;
}

/* which pushes the work back one step. Scoped with :has() because
   Writing has no subtitle and must keep counting from zero — the
   offset belongs to panels that actually have a line to read first. */
.tabs__panel.is-entering:has(> .section__sub) [data-stagger] > * {
  animation-delay: calc((var(--i, 0) + 1) * 55ms);
}

@keyframes tab-panel-out {
  to { opacity: 0; }
}

@keyframes tab-panel-in {
  from { opacity: 0; transform: translate3d(0, 10px, 0); }
  to   { opacity: 1; transform: none; }
}

/* ── WRITINGS LIST ─────────────────────────────────────────── */

.posts {
  display: flex;
  flex-direction: column;
  gap: 40px;
  list-style: none;
}

/* the whole block is the target; the accessible name comes from an
   aria-label on the link so a screen reader hears the title, not the
   title plus byline plus excerpt run together */
.post__link {
  display: flex;
  flex-direction: column;
  gap: 12px;
  color: inherit;
  text-decoration: none;
}

.post__head {
  display: flex;
  align-items: center;
  gap: 12px;
}

.post__icon { flex-shrink: 0; }

.post__title {
  display: flex;
  flex-direction: column;
  min-width: 0;
}

.post__name {
  font-size: var(--heading-sm-size);
  line-height: var(--heading-sm-line);
  font-weight: 500;
  color: var(--text-primary);
}

.post__meta,
.post__excerpt {
  font-size: var(--body-md-size);
  line-height: var(--body-md-line);
  color: var(--text-secondary);
}

.post__excerpt { font-weight: 200; }

@media (hover: hover) and (pointer: fine) {
  .post__link:hover .post__name {
    text-decoration: underline;
    text-decoration-thickness: 1px;
    text-underline-offset: 3px;
  }
}

.post__link:focus-visible {
  outline: 1px solid var(--text-primary);
  outline-offset: 4px;
  border-radius: 2px;
}

/* ── INLINE LINKS ──────────────────────────────────────────── */

.link {
  display: inline;
  font: inherit;
  color: var(--text-secondary);
  background: none;
  border: 0;
  padding: 0;
  text-align: left;
  cursor: pointer;
  text-decoration: underline;
  text-decoration-thickness: 1px;
  text-decoration-color: var(--border-default);
  text-underline-offset: 3px;
  transition: color var(--dur-micro) var(--ease-out),
              text-decoration-color var(--dur-micro) var(--ease-out);
}

.link:hover,
.link:focus-visible {
  color: var(--text-primary);
  text-decoration-color: currentColor;
}

.link:focus-visible {
  outline: 1px solid var(--text-primary);
  outline-offset: 3px;
  border-radius: 1px;
}

/* ── CASE STUDY BOXES ──────────────────────────────────────── */

.showcase {
  display: flex;
  flex-direction: column;
  gap: var(--block-gap);
}

.showcase__row {
  display: flex;
  gap: var(--block-gap);
  align-items: stretch;
}

.case {
  display: block;
  position: relative;
  overflow: hidden;
  background: var(--base-white);
  border: 1px solid var(--border-default);
  border-radius: var(--radius-card);
  color: inherit;
  text-decoration: none;
  transition: border-color var(--dur-small) var(--ease-out);
}

.case:hover { border-color: var(--border-hover); }

.case:focus-visible {
  outline: 1px solid var(--text-primary);
  outline-offset: 2px;
}

.case--wide { width: 100%; height: 468px; }

.case--tall { flex: 1 1 0; min-width: 0; height: 468px; }

.stage {
  display: flex;
  align-items: center;
  justify-content: center;
  width: 100%;
  height: 100%;
  overflow: hidden;
}

/* knocks the mockup back so the plate is the only thing to read.
   Sits after .stage and before .case__label in the markup, so paint order
   alone puts it between them without any z-index bookkeeping. */
.case__scrim {
  position: absolute;
  inset: 0;
  background: rgba(28, 25, 23, 0.5);
  opacity: 0;
  pointer-events: none;
  transition: opacity var(--dur-plate) var(--ease-out);
}

@media (hover: hover) and (pointer: fine) {
  .case:hover .case__scrim,
  .case:focus-visible .case__scrim { opacity: 1; }
}

/* label plate */
.case__label {
  position: absolute;
  left: 16px;
  right: 16px;
  bottom: 16px;
  display: flex;
  flex-direction: column;
  gap: 8px;
  padding: 14px 16px;
  background: var(--base-white);
  border: 1px solid var(--border-default);
  border-radius: var(--radius-card);
  pointer-events: none;
  opacity: 1;
  transform: translateY(0);
  /* opacity settles faster than the travel, so the plate is solid for most
     of the way up instead of only arriving once it has stopped */
  transition: transform var(--dur-plate) var(--ease-out),
              opacity 180ms var(--ease-out);
}

/* the mockup runs on its own, so the plate stays out of the way until
   there's a cursor asking what the case is. It rises out of the bottom
   edge rather than appearing in place. On touch it stays visible, since
   nothing else names the case. */
@media (hover: hover) and (pointer: fine) {
  .case__label {
    opacity: 0;
    transform: translateY(calc(100% + 16px));
  }

  .case:hover .case__label,
  .case:focus-visible .case__label {
    opacity: 1;
    transform: translateY(0);
  }
}

/* same pairing as a section head: Body/sm in primary over Body/lg in
   secondary, no tracking — the copy already carries its own caps */
.case__tag {
  font-size: var(--body-sm-size);
  line-height: var(--body-sm-line);
  color: var(--text-primary);
}

.case__desc {
  font-size: var(--body-lg-size);
  line-height: var(--body-lg-line);
  color: var(--text-secondary);
}

/* ── DEVICE: LAPTOP (internal ops dashboard) ───────────────── */

.case--wide { --laptop-w: 470px; }

.laptop {
  display: block;
  flex: 0 0 auto;
  width: var(--laptop-w);
  padding: 0 6px 8px;
  background: #1a1a1a;
  border-radius: 12px;
  box-shadow: 0 0 0 1px #2a2a2a, 0 16px 40px rgba(28, 25, 23, 0.28);
}

.laptop__bar {
  display: block;
  position: relative;
  height: 8px;
}

.laptop__notch {
  position: absolute;
  top: 0;
  left: 50%;
  transform: translateX(-50%);
  display: flex;
  align-items: center;
  justify-content: center;
  width: 80px;
  height: 18px;
  background: #1a1a1a;
  border-radius: 0 0 12px 12px;
}

.laptop__cam {
  width: 5px;
  height: 5px;
  margin-top: 6px;
  background: #333;
  border-radius: 50%;
}

.laptop__screen {
  display: block;
  position: relative;
  width: 100%;
  aspect-ratio: 3024 / 1964;
  overflow: hidden;
  border-radius: 6px;
  background: var(--base-white);
}

.laptop__screen .slide img { object-position: top left; }

/* ── DEVICE: PHONE (loyalty + hangry) ──────────────────────── */

/* one handset for both boxes — box 2 and box 3 sit side by side, so any
   difference in size or radius reads as a mistake */
.phone {
  display: block;
  position: relative;
  flex: 0 0 auto;
  width: 210px;
  height: 420px;
  padding: 8px;
  background: #1a1a1a;
  border-radius: 32px;
  box-shadow: 0 0 0 1px #333, 0 20px 48px rgba(28, 25, 23, 0.26);
}

.phone__screen {
  display: block;
  position: relative;
  width: 100%;
  height: 100%;
  overflow: hidden;
  border-radius: 25px;
  background: var(--base-white);
}

/* ── SLIDES + SCROLLERS ────────────────────────────────────── */

.slide {
  display: block;
  position: absolute;
  inset: 0;
  overflow: hidden;
  opacity: 0;
  transition: opacity 400ms ease;
}

.slide.is-active { opacity: 1; }

.slide.no-fade { transition: none; }

.slide img {
  width: 100%;
  height: 100%;
  object-fit: cover;
  object-position: top;
}

.slide--scroll img {
  height: auto;
  min-height: 100%;
}

.scroller {
  width: 100%;
  height: auto;
}

/* ── FAKE CURSOR ───────────────────────────────────────────── */

.pointer {
  position: absolute;
  z-index: 3;
  width: 14px;
  height: 14px;
  opacity: 0;
  transform: translate(-50%, -50%);
  pointer-events: none;
  transition: opacity 200ms ease, transform var(--dur-micro) ease;
}

.pointer.is-visible { opacity: 1; }
.pointer.is-pressing { transform: translate(-50%, -50%) scale(0.8); }

.ripple {
  position: absolute;
  z-index: 2;
  width: 28px;
  height: 28px;
  border-radius: 50%;
  background: rgba(28, 25, 23, 0.15);
  opacity: 0;
  transform: translate(-50%, -50%) scale(0);
  pointer-events: none;
}

.ripple.is-popping { animation: ripple-pop 400ms ease-out forwards; }

@keyframes ripple-pop {
  from { transform: translate(-50%, -50%) scale(0); opacity: 0.5; }
  to   { transform: translate(-50%, -50%) scale(2.5); opacity: 0; }
}

/* ── FOOTER ────────────────────────────────────────────────── */

.footer {
  display: flex;
  flex-direction: column;
  gap: var(--block-gap);
  padding-top: 0;
  border-top: 1px solid var(--border-default);
}

/* copyright on the left, theme switch on the right. The 24px under the
   rule used to hang off .footer__note; it moves here now that the note
   has a sibling to stay level with. */
.footer__row {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: var(--block-gap);
  padding-top: var(--block-gap);
}

.footer__note {
  font-size: var(--body-sm-size);
  line-height: var(--body-sm-line);
}

/* ── THEME SWITCH ──────────────────────────────────────────────
   A real <button role="switch">, so the label is its accessible name
   and the state is announced rather than implied by colour. The track
   is decorative: everything a screen reader needs is on the button.
   ────────────────────────────────────────────────────────────── */

.theme {
  display: inline-flex;
  flex-shrink: 0;
  align-items: center;
  gap: 12px;
  padding: 0;
  background: none;
  border: 0;
  font: inherit;
  color: var(--text-secondary);
  cursor: pointer;
}

.theme__label {
  font-size: var(--caption-size);
  line-height: var(--caption-line);
  letter-spacing: var(--caption-track);
  white-space: nowrap;
}

.theme__track {
  display: flex;
  flex-shrink: 0;
  width: 36px;
  height: 20px;
  padding: 2px;
  border-radius: 100px;
  background: var(--border-default);
  transition: background var(--dur-small) var(--ease-out);
}

.theme[aria-checked="true"] .theme__track {
  background: var(--accent-gradient);
}

.theme__knob {
  display: block;
  width: 16px;
  height: 16px;
  border-radius: 50%;
  /* stays white in both modes — it is the knob of a physical switch,
     not a surface that belongs to the palette */
  background: #ffffff;
  transition: transform var(--dur-small) var(--ease-out);
}

/* 36 track − 2 padding each side − 16 knob = 16 to travel */
.theme[aria-checked="true"] .theme__knob { transform: translateX(16px); }

.theme:focus-visible {
  outline: 1px solid var(--text-primary);
  outline-offset: 4px;
  border-radius: 2px;
}

@media (hover: hover) and (pointer: fine) {
  .theme:hover .theme__label { color: var(--text-primary); }
}

/* ── TOAST ─────────────────────────────────────────────────── */

.toast {
  position: fixed;
  left: 50%;
  bottom: 24px;
  z-index: 200;
  padding: 8px 16px;
  background: var(--text-primary);
  color: var(--base-white);
  font-size: var(--body-sm-size);
  line-height: var(--body-sm-line);
  opacity: 0;
  transform: translate(-50%, 8px);
  pointer-events: none;
  transition: opacity var(--dur-small) var(--ease-out),
              transform var(--dur-small) var(--ease-out);
}

.toast.is-visible { opacity: 1; transform: translate(-50%, 0); }

/* ── CURSOR TOOLTIP ────────────────────────────────────────── */

/* position comes from JS as two custom properties, so the tip tracks the
   pointer on the compositor instead of through left/top reflows */
.cursor-tip {
  position: fixed;
  top: 0;
  left: 0;
  z-index: 300;
  padding: 6px 10px;
  background: var(--text-primary);
  color: var(--base-white);
  font-size: var(--body-sm-size);
  line-height: var(--body-sm-line);
  border-radius: 6px;
  white-space: nowrap;
  pointer-events: none;
  opacity: 0;
  transform: translate3d(var(--tip-x, 0px), var(--tip-y, 0px), 0);
  transition: opacity var(--dur-micro) var(--ease-out);
}

.cursor-tip.is-visible { opacity: 1; }

/* ── EXPERIMENTS ───────────────────────────────────────────────
   Five toys under the Experiment tab. Every card is the same two
   panels floating on a 6px tray: a white stage holding the thing
   you can touch, and a note under it saying what it does. The tray
   is what gives the card its inset look without a second frame.

   The cards are all different heights because the toys are, and
   only the width is fixed. Nothing here is a link — these are the
   one part of the page that answers back rather than going
   somewhere.
   ────────────────────────────────────────────────────────────── */

.lab {
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: var(--block-gap);
  align-items: start;
}

/* Two independent columns rather than grid rows. The cards are
   deliberately unequal, and rows would tie each pair together — the
   shorter card would grow a gap under it instead of letting the next
   one slide up to meet it. */
.lab__col {
  display: flex;
  flex-direction: column;
  gap: var(--block-gap);
  min-width: 0;
}

.lab-card {
  display: flex;
  flex-direction: column;
  gap: 6px;
  padding: 6px;
  background: var(--bg-subtle);
  border: 1px solid var(--border-default);
  border-radius: var(--radius-card);
}

.lab-card__stage {
  position: relative;
  display: flex;
  align-items: center;
  justify-content: center;
  /* 24 from the panel, 10 more that the toy carries itself */
  padding: 34px 24px;
  background: var(--base-white);
  border-radius: var(--radius-card) var(--radius-card) 0 0;
  /* confetti falls past the bottom of the panel and has to stop
     there; the torn coupon swings out sideways and must not */
  overflow: hidden;
  /* the two fixed-size drawings measure themselves against this box,
     not against the viewport — see --u below */
  container-type: inline-size;
}

.lab-card__note {
  padding: 16px 24px;
  background: var(--base-white);
  border-radius: 0 0 var(--radius-card) var(--radius-card);
  font-size: var(--label-size);
  line-height: var(--label-line);
  color: var(--text-secondary);
}

/* One unit of the 276px-wide artwork the design is drawn at. Below
   that width the whole drawing shrinks together — type, strokes and
   spacing on the same factor — instead of the type staying put while
   the frame closes in on it. It has to be declared on a child of the
   stage: an element can never be its own query container. */
.tilt,
.coupon { --u: calc(min(276px, 100cqw) / 276); }

/* ── shared button ─────────────────────────────────────────── */

/* the ink chip the site uses everywhere: primary background, page
   background as the label, so dark mode flips it without a rule */
.lab-btn {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  gap: 4px;
  padding: 7px 8px;
  background: var(--text-primary);
  border: 0;
  border-radius: 8px;
  font-family: inherit;
  font-size: var(--label-size);
  line-height: var(--label-line);
  font-weight: 500;
  color: var(--base-white);
  white-space: nowrap;
  cursor: pointer;
  transition: background var(--dur-micro) var(--ease-out),
              transform var(--dur-micro) var(--ease-out);
}

.lab-btn svg { display: block; flex-shrink: 0; }

/* the press is the only feedback that lands before the animation
   does, so it has to be immediate */
.lab-btn:active { transform: scale(0.96); }

@media (hover: hover) and (pointer: fine) {
  .lab-btn:hover { background: var(--btn-hover); }
}

.lab-btn:focus-visible {
  outline: 1px solid var(--text-primary);
  outline-offset: 3px;
}

/* ── CARD: CONFETTI ────────────────────────────────────────────
   Pieces are spawned into the stage, not the card, so the shower is
   bounded by the white panel exactly as drawn. Each one carries its
   own fall distance, drift, spin and duration as custom properties
   and shares a single keyframe — forty elements, one animation.
   ────────────────────────────────────────────────────────────── */

.confetti {
  position: absolute;
  inset: 0;
  pointer-events: none;
}

.confetti i {
  position: absolute;
  top: -14px;
  display: block;
  will-change: transform, opacity;
  animation: lab-confetti var(--dur, 1.4s) cubic-bezier(0.3, 0.15, 0.65, 1) var(--delay, 0s) both;
}

/* gravity is not symmetric, so neither is the curve — a piece leaves
   the top slowly and is moving fastest as it clears the bottom edge.
   The fade is at the very end only, so nothing vanishes while it is
   still in open air. */
@keyframes lab-confetti {
  0%   { transform: translate3d(0, 0, 0) rotate(0deg); opacity: 0; }
  6%   { opacity: 1; }
  85%  { opacity: 1; }
  100% {
    transform: translate3d(var(--sway, 0px), var(--fall, 160px), 0) rotate(var(--spin, 360deg));
    opacity: 0;
  }
}

/* ── CARD: RANDOMIZE ───────────────────────────────────────── */

.fn {
  display: flex;
  align-items: stretch;
  gap: 5px;
  height: 43px;
  padding: 5px;
  background: var(--base-white);
  border: 1px solid var(--border-default);
  border-radius: var(--radius-card);
}

.fn__result {
  display: flex;
  align-items: center;
  justify-content: center;
  /* the string is always the same length, and this is what it
     measures — the box must not breathe while characters churn */
  min-width: 104px;
  padding: 0 8px;
  background: var(--bg-muted);
  border-radius: var(--radius-card);
  font-size: var(--label-size);
  line-height: var(--label-line);
  font-weight: 500;
  font-variant-numeric: tabular-nums;
  color: var(--text-primary);
  white-space: nowrap;
  transition: color var(--dur-micro) var(--ease-out);
}

.fn.is-rolling .fn__result { color: var(--text-secondary); }

.fn__btn {
  padding: 0 8px;
  font-size: var(--caption-size);
  line-height: var(--caption-line);
  letter-spacing: var(--caption-track);
}

/* both icons occupy the same 14px, so the button never changes width
   when the tick takes over from the arrows */
.fn__icon {
  position: relative;
  flex-shrink: 0;
  width: 14px;
  height: 14px;
}

.fn__icon svg {
  position: absolute;
  inset: 0;
  transition: opacity 140ms var(--ease-out),
              transform 260ms var(--ease-out);
}

.fn__icon .fn__done { opacity: 0; transform: scale(0.4); }

.fn.is-done .fn__icon .fn__idle { opacity: 0; transform: scale(0.4) rotate(-90deg); }
.fn.is-done .fn__icon .fn__done { opacity: 1; transform: none; }

/* the arrows turn only while the characters are churning, which is
   the one moment the button describes work rather than offering it */
.fn.is-rolling .fn__icon .fn__idle {
  animation: lab-spin 620ms linear infinite;
}

@keyframes lab-spin { to { transform: rotate(360deg); } }

/* ── CARD: PILLS ───────────────────────────────────────────────
   The same idea as the tabs at the top of the page — one plate that
   travels rather than a background that swaps — but in ink instead
   of the accent, because these do not carry page state.
   ────────────────────────────────────────────────────────────── */

.mini-pills {
  position: relative;
  display: flex;
  gap: 4px;
}

.mini-pills__thumb {
  position: absolute;
  top: 0;
  left: 0;
  width: var(--pill-w, 76px);
  height: 100%;
  border-radius: 20px;
  background: var(--text-primary);
  /* width travels too: the three labels are different lengths, so the
     plate resizes on the way rather than on arrival */
  transform: translate3d(var(--pill-x, 0px), 0, 0);
  transition: transform var(--dur-plate) var(--ease-out),
              width var(--dur-plate) var(--ease-out);
}

.mini-pill {
  position: relative;
  z-index: 1;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  gap: 4px;
  height: 32px;
  padding: 0 12px;
  background: none;
  border: 0;
  border-radius: 20px;
  font-family: inherit;
  font-size: var(--label-size);
  line-height: var(--label-line);
  font-weight: 500;
  color: var(--text-primary);
  white-space: nowrap;
  cursor: pointer;
  /* slower than the plate, so the ink catches up to it rather than
     racing it there */
  transition: color var(--dur-plate) var(--ease-out);
}

.mini-pill svg { display: block; flex-shrink: 0; }

.mini-pill[aria-pressed="true"] { color: var(--base-white); }

@media (hover: hover) and (pointer: fine) {
  .mini-pill[aria-pressed="false"]:hover { color: var(--text-secondary); }
}

.mini-pill:focus-visible {
  outline: 1px solid var(--text-primary);
  outline-offset: 2px;
}

/* ── CARD: TILT ────────────────────────────────────────────────
   The card in the design is three blurred circles under a 30% black
   wash. Blurring three real circles costs a 100px backdrop filter on
   every frame of a transform, so the same shape is painted as three
   radial gradients instead, with the wash folded into their stops.
   ────────────────────────────────────────────────────────────── */

.tilt {
  width: calc(276 * var(--u));
  height: calc(140 * var(--u));
  /* shallow, so the far edge does not stretch — this is a card being
     tipped on a desk, not a plane banking */
  perspective: 700px;
}

.tilt__card {
  position: relative;
  width: 100%;
  height: 100%;
  border-radius: calc(8 * var(--u));
  overflow: hidden;
  transform-style: preserve-3d;
  transform: rotateX(var(--rx, 0deg)) rotateY(var(--ry, 0deg));
  /* the long curve is the release; tracking swaps it out below */
  transition: transform 520ms var(--ease-out);
  background:
    radial-gradient(115% 145% at 50% -30%, #5b5b5b 0%, rgba(91, 91, 91, 0) 62%),
    radial-gradient(80% 130% at 10% 135%, #0c0b09 0%, rgba(12, 11, 9, 0) 68%),
    radial-gradient(80% 130% at 92% 140%, #0c0b09 0%, rgba(12, 11, 9, 0) 68%),
    #171512;
  will-change: transform;
}

/* while the pointer is on the card the transform has to be a direct
   readout of where it is, so the easing comes off and only enough
   duration is left to smooth the sampling */
.tilt.is-live .tilt__card { transition: transform 90ms linear; }

/* the grain in the design, 13% over everything. Generated rather than
   fetched: a 180px tile of fractal noise costs one data URI and no
   request. */
.tilt__card::after {
  content: "";
  position: absolute;
  inset: 0;
  opacity: 0.13;
  pointer-events: none;
  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='180' height='180'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='3' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='180' height='180' filter='url(%23n)'/%3E%3C/svg%3E");
  background-size: calc(180 * var(--u)) calc(180 * var(--u));
}

/* a highlight that sits where the pointer is. Tilting alone reads as
   a picture being rotated; a light that stays put while the card
   turns under it is what makes the surface read as a surface. */
.tilt__glow {
  position: absolute;
  inset: 0;
  pointer-events: none;
  background: radial-gradient(
    calc(200 * var(--u)) circle at var(--mx, 50%) var(--my, 50%),
    rgba(255, 255, 255, 0.18) 0%,
    rgba(255, 255, 255, 0) 62%
  );
  opacity: 0;
  transition: opacity 320ms var(--ease-out);
}

.tilt.is-live .tilt__glow { opacity: 1; }

/* the parallax. The logo sits 40px off the card's own plane, so the
   perspective carries it further than the card travels — which is the
   whole difference between a tilt and a tilt with depth. */
.tilt__logo {
  position: absolute;
  left: 50%;
  top: 50%;
  width: calc(40 * var(--u));
  height: calc(40 * var(--u));
  transform: translate3d(
    calc(-50% + var(--lx, 0px)),
    calc(-50% + var(--ly, 0px)),
    calc(40 * var(--u))
  );
  transition: transform 520ms var(--ease-out);
}

.tilt.is-live .tilt__logo { transition: transform 90ms linear; }

.tilt__logo svg { display: block; width: 100%; height: 100%; }

/* ── CARD: COUPON ──────────────────────────────────────────────
   The coupon is drawn twice and each copy clipped to one side of the
   same ragged line, so the two halves compose back into one intact
   ticket with no seam to hide. That also means the tear needs no
   JavaScript to exist, only to fire: without scripting the card is a
   coupon that simply does not come apart.

   The ragged line runs either side of 31.34%, which is where the
   perforation is — the tear has to start from the dashes, not from
   the middle of the paper.
   ────────────────────────────────────────────────────────────── */

/* Two of these are undoing the UA's button styling, and both are the
   kind that only show up once the design is on screen. A button does
   not inherit font-family, so every word on the ticket was being set
   in the UA's own face — wider than Geist, which is what pushed OFF
   onto a second line. And a button centres its text, so the whole
   right-hand column was centred rather than ranged left. */
.coupon {
  position: relative;
  width: calc(276 * var(--u));
  height: calc(128 * var(--u));
  padding: 0;
  background: none;
  border: 0;
  font: inherit;
  color: inherit;
  text-align: left;
  cursor: pointer;
}

.coupon:focus-visible {
  outline: 1px solid var(--text-primary);
  outline-offset: 6px;
  border-radius: 4px;
}

.coupon__half {
  position: absolute;
  inset: 0;
  transition: transform 480ms var(--ease-out),
              filter 480ms var(--ease-out);
}

/* the clip lives one level in, so the shadow painted on the half is
   not cut off by the very edge it exists to describe */
.coupon__clip {
  position: absolute;
  inset: 0;
}

.coupon__half--left .coupon__clip {
  clip-path: polygon(0 0, 31.3% 0, 29.6% 9%, 33% 18%, 30.2% 27%, 32.8% 36%, 29.8% 45%, 32.9% 54%, 30.4% 63%, 33.2% 72%, 30% 81%, 32.6% 90%, 30.8% 100%, 0 100%);
}

.coupon__half--right .coupon__clip {
  clip-path: polygon(31.3% 0, 100% 0, 100% 100%, 30.8% 100%, 32.6% 90%, 30% 81%, 33.2% 72%, 30.4% 63%, 32.9% 54%, 29.8% 45%, 32.8% 36%, 30.2% 27%, 33% 18%, 29.6% 9%);
}

/* two units of tension before anything has happened — enough to say
   the halves are separate objects, not enough to look broken */
@media (hover: hover) and (pointer: fine) {
  .coupon:hover .coupon__half--left  { transform: translate3d(calc(-2 * var(--u)), 0, 0); }
  .coupon:hover .coupon__half--right { transform: translate3d(calc(2 * var(--u)), 0, 0); }
}

/* Asymmetric on purpose: paper does not come apart evenly. The shadow
   arrives with the movement, because the halves are off the table for
   exactly as long as they are apart. Travel is kept inside the stage's
   24px of side padding, rotation included, so nothing is clipped
   mid-swing. */
.coupon.is-torn .coupon__half--left,
.coupon:hover.is-torn .coupon__half--left {
  transform: translate3d(calc(-11 * var(--u)), calc(7 * var(--u)), 0) rotate(-5deg);
}

.coupon.is-torn .coupon__half--right,
.coupon:hover.is-torn .coupon__half--right {
  transform: translate3d(calc(10 * var(--u)), calc(-6 * var(--u)), 0) rotate(4deg);
}

.coupon.is-torn .coupon__half {
  filter: drop-shadow(0 calc(6 * var(--u)) calc(10 * var(--u)) rgba(28, 25, 23, 0.18));
}

.coupon__face { position: absolute; inset: 0; }

/* the outline is stroked down the middle of the path, so the path is
   scaled in by one unit on each side to keep the whole 2px inside the
   box — a stroke hanging over the edge would be shaved off by the clip */
.coupon__paper {
  position: absolute;
  inset: 0;
  display: block;
  width: 100%;
  height: 100%;
}

.coupon__perf {
  position: absolute;
  left: 31.34%;
  top: 6.25%;
  width: 0;
  height: 87.5%;
  border-left: 1px dashed var(--coupon-ink);
  opacity: 0.45;
}

.coupon__barcode {
  position: absolute;
  left: 5.8%;
  top: 12.5%;
  display: block;
  width: 19.93%;
  height: 75%;
}

.coupon__copy {
  position: absolute;
  left: 40.04%;
  top: 12.5%;
  width: 50%;
}

/* the face is built from spans, because all of it lives inside a
   <button> and only phrasing content is allowed in there */
.coupon__copy > span { display: block; }

/* A 36px glyph and a 24px one on one 31.2px line make a line box
   taller than either, because they are aligned on the baseline and
   their half-leading differs. Left alone that pushes everything under
   it down by three pixels, so the block is held at the height the
   design gives it and the line is allowed to sit proud of it.

   That fixed height is also why this line must never wrap: a second
   line would overflow the block and land on top of the sentence
   below it rather than pushing it down. The headline is one line by
   design, so it is nailed to one line here. */
.coupon__deal {
  height: calc(32 * var(--u));
  white-space: nowrap;
  font-size: calc(24 * var(--u));
  line-height: calc(31.2 * var(--u));
  font-weight: 300;
  letter-spacing: var(--heading-xl-track);
  color: var(--coupon-ink-soft);
}

.coupon__deal b {
  font-size: calc(36 * var(--u));
  font-weight: 700;
  color: var(--coupon-ink);
}

.coupon__hint {
  margin-top: calc(8 * var(--u));
  font-size: calc(11 * var(--u));
  line-height: calc(15.4 * var(--u));
  letter-spacing: var(--caption-track);
  color: var(--coupon-ink-soft);
}

.coupon__expiry {
  margin-top: calc(24 * var(--u));
  font-size: calc(11 * var(--u));
  line-height: calc(15.4 * var(--u));
  letter-spacing: var(--caption-track);
  font-weight: 200;
  color: var(--coupon-ink-soft);
}

/* ── RESPONSIVE ────────────────────────────────────────────── */

@media (max-width: 860px) {
  :root {
    --shell-pad-y: 72px;
    --shell-pad-x: 28px;
    --stack-gap: 64px;
  }
}

@media (max-width: 720px) {
  :root {
    --shell-pad-y: 56px;
    --shell-pad-x: 20px;
  }

  .showcase__row { flex-direction: column; }

  /* the stage carries the height here, not the box: `flex: 1 1 0` would
     resolve against the cross axis once the row stacks and collapse it */
  .case { display: flex; flex-direction: column; }
  .case--wide,
  .case--tall { flex: 0 0 auto; height: auto; }
  .case--wide .stage { height: 240px; }
  .case--tall .stage { height: 430px; }

  .case--wide { --laptop-w: 320px; }

  .phone { width: 190px; height: 380px; }

  /* nothing reveals the mockup on touch, so the plate sits under it
     instead of covering it */
  .case__label {
    position: static;
    padding: 14px;
    border: 0;
    border-top: 1px solid var(--border-default);
    /* once it's in the flow it can't hide on hover, or it leaves a hole */
    opacity: 1;
    transform: none;
    /* flush to the box edges here, so rounding it would read as a seam */
    border-radius: 0;
  }

  /* one card per row, still two columns' worth of order */
  .lab { grid-template-columns: 1fr; }

  /* buying the drawings back the width they need before --u has to
     start shrinking them */
  .lab-card__stage { padding: 28px 16px; }
}

/* the narrowest phones still in use. The pill row is the one toy with
   no scaling of its own — it is type, not artwork — so it gives up
   padding instead. */
@media (max-width: 400px) {
  .mini-pill { padding: 0 9px; }
}

/* ── REDUCED MOTION ────────────────────────────────────────── */

@media (prefers-reduced-motion: reduce) {
  *,
  *::before,
  *::after {
    animation-duration: 0.01ms !important;
    animation-iteration-count: 1 !important;
    transition-duration: 0.01ms !important;
    scroll-behavior: auto !important;
  }
  /* the deck never starts, so the plate has to stay readable */
  .case__label { opacity: 1 !important; }

  /* the surname is filled in outright, so there is nothing to blink for */
  .identity__caret { display: none; }

  /* a shower of paper has no calm version of itself, so it does not
     run at all here. The button still answers with its press, and the
     note under the card still says what it would have done. */
  .confetti { display: none; }
}
