/* =============================================================================
   site.css — the base every page on this site shares.

   Split out when the site became an umbrella for more than one app: the
   palette, header, reveal system and section rhythm are the thing that makes
   separate pages read as one site, and duplicating 200 lines of it per page
   is how they drift apart. Page-specific CSS still lives inline in the page
   that needs it.

   Same-origin, so the strict `style-src 'self'` in _headers still covers it.
   ============================================================================= */



/* ==========================================================================
   Palette — taken from the app's own brand marks so the site and the icon
   read as one product.
   ========================================================================== */
:root {
  --brand-start: #6b70f5;
  --brand-end:   #995ceb;

  --ink:      #0d0b1a;
  --ink-soft: #14112a;

  /* A frosted surface rather than a white wash.
     A white tint cannot fix legibility on its own: the ambient blobs drift
     behind the content, and where one sits under a card the backdrop is
     already bright — adding more white pushes the type closer to what it sits
     on rather than away from it. So the ink layer underneath is doing the
     work, guaranteeing white text a dark substrate whatever passes behind,
     and the highlight on top keeps the result reading as glass instead of a
     black box. Components that carry this also take `--glass-blur`, which is
     what stops it looking like a flat scrim. */
  --surface: linear-gradient(180deg, rgba(255,255,255,.07), rgba(255,255,255,.025)),
             rgba(13,11,26,.42);
  --surface-hover: linear-gradient(180deg, rgba(255,255,255,.10), rgba(255,255,255,.04)),
                   rgba(13,11,26,.54);
  /* Deliberately mild. Enough to separate a card from what it covers, not so
     much that the palette behind it is lost — and cheap enough to sit on a
     dozen elements at once. */
  --glass-blur: blur(12px) saturate(130%);

  --border:   rgba(255,255,255,.10);

  --text:     #f3f1ff;
  --text-dim: #b9b4d6;
  --text-mut: #837da6;

  --radius: 22px;
  --shell:  min(100% - 40px, 1120px);

  /* One easing everywhere. Motion that shares a curve reads as one system. */
  --ease: cubic-bezier(.22,.68,.24,1);
}

* { box-sizing: border-box; }

html { scroll-behavior: smooth; }

body {
  margin: 0;
  background: var(--ink);
  color: var(--text);
  font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif;
  line-height: 1.65;
  -webkit-font-smoothing: antialiased;
  overflow-x: hidden;
}

a { color: inherit; }

.shell { width: var(--shell); margin-inline: auto; }

.skip {
  position: absolute; left: -9999px;
  background: var(--text); color: var(--ink);
  padding: 12px 18px; border-radius: 10px; z-index: 100;
}
.skip:focus { left: 16px; top: 16px; }

:focus-visible {
  outline: 2px solid var(--brand-start);
  outline-offset: 3px;
  border-radius: 6px;
}

/* ==========================================================================
   Ambient background — a slow drifting mesh, echoing the app's own animated
   gradient. Fixed and behind everything, so scrolling reveals content over a
   surface that is itself alive.
   ========================================================================== */
.ambient {
  position: fixed; inset: 0; z-index: -2;
  overflow: hidden;
  pointer-events: none;
}
.ambient span {
  position: absolute;
  width: 55vmax; height: 55vmax;
  border-radius: 50%;
  filter: blur(90px);
  opacity: .5;
  will-change: transform;
}
.ambient span:nth-child(1) {
  background: radial-gradient(circle, var(--brand-start), transparent 62%);
  top: -18vmax; left: -12vmax;
  animation: drift-a 26s var(--ease) infinite alternate;
}
.ambient span:nth-child(2) {
  background: radial-gradient(circle, var(--brand-end), transparent 62%);
  top: 32vmax; right: -18vmax;
  animation: drift-b 31s var(--ease) infinite alternate;
}
.ambient span:nth-child(3) {
  background: radial-gradient(circle, #4d3ae0, transparent 66%);
  bottom: -22vmax; left: 24vmax;
  animation: drift-c 37s var(--ease) infinite alternate;
}
@keyframes drift-a { to { transform: translate3d(9vmax, 6vmax, 0) scale(1.14); } }
@keyframes drift-b { to { transform: translate3d(-11vmax, -7vmax, 0) scale(1.08); } }
@keyframes drift-c { to { transform: translate3d(6vmax, -9vmax, 0) scale(1.17); } }

/* Faint quote marks drifting behind the hero — the same motif as the app's
   launch screen, so opening the app after visiting feels continuous. */
.marks { position: absolute; inset: 0; z-index: -1; pointer-events: none; overflow: hidden; }
.marks b {
  position: absolute;
  font-family: Georgia, "Times New Roman", serif;
  font-weight: 700;
  color: #fff;
  opacity: .05;
  animation: twinkle 6s ease-in-out infinite;
}
@keyframes twinkle {
  0%, 100% { opacity: .035; transform: scale(.96); }
  50%      { opacity: .13;  transform: scale(1.04); }
}

/* ==========================================================================
   Scroll reveal — the angle is the point. Elements arrive tipped away from
   the reader and settle flat, which reads as depth rather than as a fade.
   ========================================================================== */
.reveal {
  opacity: 0;
  transform: perspective(1200px) rotateX(9deg) translate3d(0, 46px, -60px);
  transition:
    opacity .85s var(--ease) var(--delay, 0s),
    transform .95s var(--ease) var(--delay, 0s);
  will-change: opacity, transform;
}
.reveal[data-tilt="left"]  { transform: perspective(1200px) rotateY(-9deg) translate3d(-40px, 30px, -50px); }
.reveal[data-tilt="right"] { transform: perspective(1200px) rotateY(9deg)  translate3d(40px, 30px, -50px); }
.reveal[data-tilt="rise"]  { transform: perspective(1200px) rotateX(14deg) translate3d(0, 70px, -90px); }
.reveal.in { opacity: 1; transform: none; }

/* ==========================================================================
   Header
   ========================================================================== */
header.top {
  position: sticky; top: 0; z-index: 40;
  backdrop-filter: blur(18px);
  -webkit-backdrop-filter: blur(18px);
  background: rgba(13,11,26,.6);
  border-bottom: 1px solid transparent;
  transition: border-color .4s var(--ease), background .4s var(--ease);
}
header.top.stuck { border-bottom-color: var(--border); background: rgba(13,11,26,.82); }
header.top .shell { display: flex; align-items: center; gap: 14px; padding: 14px 0; }

.mark {
  width: 38px; height: 38px; flex: none;
  border-radius: 12px;
  background: linear-gradient(135deg, var(--brand-start), var(--brand-end));
  display: grid; place-items: center;
  box-shadow: 0 10px 26px rgba(107,112,245,.34);
  border: 1px solid rgba(255,255,255,.2);
}
/* The tile is shared; what sits inside it identifies the page. The umbrella
   site wears the monogram, an app page wears that app's own glyph — so the
   symbol is a modifier rather than baked into `.mark`. */
.mark::before {
  font-family: Georgia, serif; font-weight: 700; color: #fff;
  line-height: 1;
}
.mark-quote::before { content: "\201C"; font-size: 30px; transform: translateY(7px); }

/* The house mark: a pearl rather than a glyph. Round, and lit from the upper
   left by an off-centre radial — a sphere reads as a sphere because the
   highlight is *not* in the middle. The two insets are the shaded underside
   and the light bouncing back into it, which is what stops it looking like a
   flat circle with a dot on it. */
.mark-pearl {
  border-radius: 50%;
  background: radial-gradient(circle at 32% 27%,
    #ffffff 0%, #efe9ff 14%, #cabdf6 40%, #8f7ee8 68%, #43349b 100%);
  box-shadow:
    0 10px 26px rgba(107,112,245,.4),
    inset -4px -5px 10px rgba(26,14,74,.55),
    inset 3px 4px 8px rgba(255,255,255,.55);
}
.mark-pearl::before { content: none; }

/* Footer-sized tile. The glyph has to shrink with it — at the full 30px a
   left quote mark overflows a 28px tile instead of sitting in it. */
.mark-sm { width: 28px; height: 28px; border-radius: 9px; }
.mark-sm.mark-quote::before { font-size: 22px; transform: translateY(5px); }
/* `.mark-sm` is declared after `.mark-pearl` and at equal specificity, so its
   9px corner would win and the pearl would render as a rounded square. */
.mark-sm.mark-pearl { border-radius: 50%; }
/* The mark and wordmark together are the way back to the umbrella site, so
   they are one link rather than two adjacent elements. */
.brand { display: flex; align-items: center; gap: 14px; text-decoration: none; color: inherit; }
.wordmark { font-family: Georgia, "Times New Roman", serif; font-weight: 700; font-size: 1.16rem; letter-spacing: .2px; }
.top nav { margin-left: auto; display: flex; gap: 26px; align-items: center; }
.top nav a { color: var(--text-dim); text-decoration: none; font-size: .93rem; transition: color .25s var(--ease); }
.top nav a:hover { color: var(--text); }
@media (max-width: 720px) { .top nav a.hide-sm { display: none; } }

/* App Store CTA in the header.
   A sibling of `nav` rather than a child of it: the other items move around
   this page, this one leaves it, and it should not inherit the link styling
   that goes with being a section anchor.

   Drawn at 40px, which is both the badge artwork's native height and Apple's
   stated minimum for the web — so it renders 1:1 with no scaling. The header's
   14px padding around a 38px mark leaves room for it without changing the bar's
   height meaningfully. The ring is on the link, not the artwork, since the
   black lockup is built for light backgrounds and would otherwise dissolve
   into the blurred bar behind it. */
.nav-badge {
  flex: none; margin-left: 12px;
  display: inline-block; border-radius: 9px;
  box-shadow: 0 0 0 1px rgba(255,255,255,.18);
  transition: box-shadow .3s var(--ease), transform .3s var(--ease);
}
.nav-badge:hover {
  transform: translateY(-1px);
  box-shadow: 0 0 0 1px rgba(255,255,255,.34), 0 10px 22px -10px rgba(0,0,0,.8);
}
.nav-badge img { display: block; height: 40px; width: auto; }
/* At the narrowest widths the wordmark yields before the CTA does. The mark
   alone still says which app this is, and the download link is the one thing
   in the bar worth keeping. */
@media (max-width: 430px) {
  .top .wordmark { display: none; }
  .nav-badge { margin-left: 6px; }
}

/* ==========================================================================
   Hero
   ========================================================================== */
.hero { padding: clamp(64px, 12vw, 130px) 0 clamp(60px, 9vw, 110px); position: relative; }
.hero-grid { display: grid; gap: clamp(40px, 6vw, 76px); grid-template-columns: 1.06fr .94fr; align-items: center; }
@media (max-width: 900px) { .hero-grid { grid-template-columns: 1fr; } }

.eyebrow {
  display: inline-flex; align-items: center; gap: 9px;
  font-size: .78rem; letter-spacing: .11em; text-transform: uppercase;
  color: var(--text-mut);
  border: 1px solid var(--border); background: var(--surface);
  backdrop-filter: var(--glass-blur);
  -webkit-backdrop-filter: var(--glass-blur);
  padding: 7px 15px; border-radius: 999px; margin: 0 0 26px;
}
.eyebrow i { width: 6px; height: 6px; border-radius: 50%; background: var(--brand-start); box-shadow: 0 0 0 4px rgba(107,112,245,.2); }

h1 {
  font-family: Georgia, "Times New Roman", serif;
  font-size: clamp(2.5rem, 6.4vw, 4.3rem);
  line-height: 1.06; margin: 0 0 22px; font-weight: 700; letter-spacing: -.5px;
}
h1 .grad {
  background: linear-gradient(115deg, var(--brand-start), var(--brand-end) 62%, #d7b6ff);
  -webkit-background-clip: text; background-clip: text; color: transparent;
}
.lede { font-size: clamp(1.04rem, 2vw, 1.2rem); color: var(--text-dim); max-width: 46ch; margin: 0 0 34px; }

.cta-row { display: flex; flex-wrap: wrap; gap: 14px; align-items: center; }
.btn {
  display: inline-flex; align-items: center; gap: 10px;
  padding: 14px 26px; border-radius: 14px;
  font-weight: 600; font-size: .96rem; text-decoration: none;
  border: 1px solid transparent;
  transition: transform .3s var(--ease), box-shadow .3s var(--ease), background .3s var(--ease);
}
.btn-primary {
  background: linear-gradient(135deg, var(--brand-start), var(--brand-end));
  color: #fff; box-shadow: 0 14px 34px rgba(107,112,245,.34);
}
.btn-primary:hover { transform: translateY(-2px); box-shadow: 0 20px 44px rgba(107,112,245,.44); }
.btn-ghost {
  border-color: var(--border); background: var(--surface); color: var(--text);
  backdrop-filter: var(--glass-blur);
  -webkit-backdrop-filter: var(--glass-blur);
}
.btn-ghost:hover { transform: translateY(-2px); background: var(--surface-hover); }

/* Without `backdrop-filter` the ink layer is all that separates these from
   what they cover, so it has to carry the contrast alone. */
@supports not ((backdrop-filter: blur(1px)) or (-webkit-backdrop-filter: blur(1px))) {
  :root {
    --surface: linear-gradient(180deg, rgba(255,255,255,.06), rgba(255,255,255,.02)),
               rgba(13,11,26,.72);
    --surface-hover: linear-gradient(180deg, rgba(255,255,255,.09), rgba(255,255,255,.03)),
                     rgba(13,11,26,.8);
  }
}
.note { font-size: .84rem; color: var(--text-mut); margin: 16px 0 0; }

/* ==========================================================================
   Sections
   ========================================================================== */
section { padding: clamp(64px, 10vw, 118px) 0; position: relative; }
.section-head { max-width: 62ch; margin-bottom: clamp(38px, 5vw, 62px); }
.kicker { font-size: .77rem; letter-spacing: .13em; text-transform: uppercase; color: var(--brand-start); font-weight: 700; margin: 0 0 14px; }
h2 { font-family: Georgia, "Times New Roman", serif; font-size: clamp(1.85rem, 3.9vw, 2.85rem); line-height: 1.16; margin: 0 0 16px; font-weight: 700; letter-spacing: -.3px; }
.sub { color: var(--text-dim); font-size: 1.03rem; margin: 0; }

.grid { display: grid; gap: 20px; }
.grid-3 { grid-template-columns: repeat(3, 1fr); }
.grid-2 { grid-template-columns: repeat(2, 1fr); }
@media (max-width: 900px) { .grid-3 { grid-template-columns: 1fr 1fr; } }
@media (max-width: 640px) { .grid-3, .grid-2 { grid-template-columns: 1fr; } }

/* No border, a shadow, and a lit top edge.
   A uniform 1px hairline on all four sides is a drawn outline: it reads the
   same whichever way the light in the rest of the page is going, and with no
   shadow under it the card sits *in* the page rather than above it. The edge
   below is a gradient instead — bright where the light is, gone by the bottom
   — which is what makes a surface look like an object. */
.card {
  border-radius: var(--radius);
  background: var(--surface);
  backdrop-filter: var(--glass-blur);
  -webkit-backdrop-filter: var(--glass-blur);
  padding: 28px;
  box-shadow:
    0 24px 60px -24px rgba(0,0,0,.65),
    inset 0 1px 0 rgba(255,255,255,.06);
  transition: box-shadow .45s var(--ease), background .45s var(--ease);
}
/* `::after` is the cursor spotlight, so the edge lives on `::before`. Masked
   rather than drawn: the gradient fills the padding box and the content box is
   punched out of it, leaving a 1px rim that can carry a gradient — which
   `border-color` cannot. */
.card::before {
  content: "";
  position: absolute; inset: 0;
  border-radius: inherit;
  padding: 1px;
  pointer-events: none;
  background: linear-gradient(160deg,
    rgba(255,255,255,.22), rgba(255,255,255,.06) 38%, rgba(255,255,255,.02));
  -webkit-mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
  -webkit-mask-composite: xor;
          mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
          mask-composite: exclude;
  transition: opacity .45s var(--ease);
}
/* No lift. The cursor spotlight already answers the hover, and a card that
   also jumps 5px is two answers to one question. */
.card:hover {
  background: var(--surface-hover);
  box-shadow:
    0 34px 74px -24px rgba(0,0,0,.75),
    inset 0 1px 0 rgba(255,255,255,.10);
}
/* Unbordered on purpose: inside an already-edged card, a second outline around
   a 46px tile is one line too many. The fill alone holds the shape. */
.card .ico {
  width: 46px; height: 46px; border-radius: 13px; display: grid; place-items: center;
  background: linear-gradient(140deg, rgba(107,112,245,.34), rgba(153,92,235,.24));
  margin-bottom: 17px;
  color: #ded9ff;
}
/* Inline SVG rather than Unicode symbols: dingbats fall back to whatever
   glyph the platform happens to have, so the same tile rendered as a crisp
   mark on one machine and a tiny stray character on another. */
.ico svg, .pledge svg { width: 22px; height: 22px; stroke: currentColor; fill: none; stroke-width: 1.7; stroke-linecap: round; stroke-linejoin: round; }
.pledge svg { width: 26px; height: 26px; }
.card h3 { font-size: 1.09rem; margin: 0 0 9px; font-weight: 650; }
.card p { color: var(--text-dim); font-size: .94rem; margin: 0; }

/* A raised slab. Named `.slab` rather than `.panel` because the deck below
   claims that word for a full page of the site — one `.panel` rule setting
   `min-height: 100svh` and another setting card padding is a collision that
   turns any card wearing the class into an accidental extra page. */
.slab {
  border-radius: var(--radius); border: 1px solid var(--border);
  background: linear-gradient(150deg, rgba(107,112,245,.14), rgba(153,92,235,.06));
  padding: clamp(24px, 3vw, 38px);
}

/* Footer ----------------------------------------------------------------- */
footer { border-top: 1px solid var(--border); padding: 44px 0 56px; color: var(--text-mut); font-size: .88rem; }
footer .shell { display: flex; flex-wrap: wrap; gap: 18px; align-items: center; }
footer nav { margin-left: auto; display: flex; gap: 22px; flex-wrap: wrap; }
footer a { color: var(--text-dim); text-decoration: none; transition: color .25s var(--ease); }
footer a:hover { color: var(--text); }

/* Closing CTA ------------------------------------------------------------ */
.close-cta {
  text-align: center;
  padding: clamp(52px, 7vw, 84px) clamp(22px, 4vw, 56px);
  border-radius: 32px; border: 1px solid var(--border);
  background: linear-gradient(150deg, rgba(107,112,245,.2), rgba(153,92,235,.09));
}
.close-cta h2 { margin-bottom: 14px; }
.close-cta .sub { margin: 0 auto 30px; max-width: 50ch; }

/* Phone frame ------------------------------------------------------------ */
/* The screens inside are real captures, exported from the app itself by
   QuoteDiaryTests/WebsiteAssetExport.swift. The frame is the only drawn part.
   `.screen` carries the screenshots' own 414 × 900 ratio rather than a nominal
   handset ratio, so nothing is cropped to fit. */
.phone-wrap { display: grid; place-items: center; perspective: 1400px; }
.phone {
  width: min(300px, 78vw);
  border-radius: 44px; padding: 11px;
  background: linear-gradient(160deg, #2a2545, #16132c);
  border: 1px solid rgba(255,255,255,.14);
  box-shadow: 0 44px 90px rgba(0,0,0,.6), 0 0 0 1px rgba(255,255,255,.05) inset;
  transform: rotateY(-13deg) rotateX(5deg) rotateZ(1deg);
  transition: transform 1s var(--ease);
}
.phone:hover { transform: rotateY(-5deg) rotateX(2deg); }
/* The 3D tilt makes the phone occupy more width than its box, which clips
   against the right edge on a narrow screen. Sit it upright there instead —
   `overflow-x: hidden` on the body would swallow the overflow silently. */
@media (max-width: 620px) { .phone, .phone:hover { transform: none; } }
.screen {
  aspect-ratio: 414/900;
  border-radius: 34px; overflow: hidden; position: relative;
  background: #000;
}
.screen img { display: block; width: 100%; height: 100%; object-fit: cover; }

/* ==========================================================================
   Reduced motion — everything still arrives, nothing moves to get there.
   ========================================================================== */
@media (prefers-reduced-motion: reduce) {
  html { scroll-behavior: auto; }
  .reveal, .reveal[data-tilt] { opacity: 1; transform: none; transition: none; }
  .ambient span, .marks b { animation: none; }
  .phone { transform: none; }
  * { transition-duration: .01ms !important; animation-duration: .01ms !important; animation-iteration-count: 1 !important; }
}

/* =============================================================================
   The deck — paginated scroll.

   Built on native CSS scroll-snap rather than a scroll-jacking library. That
   choice is the whole reason this stays usable: the trackpad keeps its own
   momentum, the scrollbar still means something, and Page Up/Down, Home/End,
   Tab and Find-in-page all behave, none of which survive a JS scroller that
   swallows wheel events. JS only observes which panel is showing.

   `min-height` rather than `height`: a panel whose content outgrows a short
   viewport must be allowed to scroll past, not clip. Snapping to `start` means
   an over-tall panel simply behaves like an ordinary section.
   ============================================================================= */
.deck {
  scroll-snap-type: y mandatory;
  /* Landing on a panel should not tuck its heading under the sticky header. */
  scroll-padding-top: var(--header-h, 68px);
}

.panel {
  min-height: 100vh;
  min-height: 100svh;          /* excludes mobile browser chrome */
  scroll-snap-align: start;
  scroll-snap-stop: always;    /* one panel per gesture, not three */
  display: grid;
  align-content: center;
  padding: calc(var(--header-h, 68px) + clamp(28px, 5vh, 64px)) 0 clamp(28px, 5vh, 64px);
  position: relative;
}

/* The closing panel carries the footer, so its content sits at the bottom
   rather than floating in the middle of the screen. */
.panel-end { align-content: space-between; }

/* Snapping is a nice-to-have, not a requirement. Drop it wherever it would
   fight the content: short/landscape viewports where panels overflow, and for
   anyone who has asked for less motion. */
@media (max-width: 860px), (max-height: 640px), (prefers-reduced-motion: reduce) {
  .deck { scroll-snap-type: none; }
  .panel { min-height: 0; padding-block: clamp(56px, 9vw, 110px); }
}

/* ==========================================================================
   Panel choreography

   Reveals inside a panel are driven by the panel's own active state instead of
   the one-shot observer used elsewhere, so leaving and returning replays the
   entrance. In a deck you revisit panels constantly; a reveal that only ever
   fires once leaves later visits looking inert.
   ========================================================================== */
.panel .reveal { transition-delay: var(--delay, 0s); }
.panel.is-active .reveal { opacity: 1; transform: none; }

/* Exit: the outgoing panel recedes rather than merely scrolling away, which is
   what stops the two panels reading as one long page. */
.panel:not(.is-active) > * { transition: opacity .5s var(--ease), transform .6s var(--ease); }
.panel.has-left > * { opacity: 0; transform: translate3d(0, -30px, 0) scale(.985); }

@media (prefers-reduced-motion: reduce) {
  .panel .reveal,
  .panel.has-left > * { opacity: 1 !important; transform: none !important; }
}

/* ==========================================================================
   Panel indicator
   ========================================================================== */
.dots {
  position: fixed; z-index: 30;
  right: clamp(14px, 2.2vw, 30px); top: 50%;
  transform: translateY(-50%);
  display: grid; gap: 16px;
  /* Hidden from the a11y tree: these duplicate the in-page nav, and a screen
     reader user has the headings. They are a sighted-scroll affordance. */
}
.dots a {
  display: block; width: 9px; height: 9px; border-radius: 50%;
  background: rgba(255,255,255,.22);
  border: 1px solid rgba(255,255,255,.28);
  transition: transform .4s var(--ease), background .4s var(--ease);
  position: relative;
}
.dots a:hover { background: rgba(255,255,255,.5); transform: scale(1.25); }
.dots a[aria-current="true"] {
  background: linear-gradient(135deg, var(--brand-start), var(--brand-end));
  transform: scale(1.5);
  box-shadow: 0 0 14px rgba(140,120,255,.8);
}
/* The label rides out of the dot on hover — no layout cost when idle. */
.dots a span {
  position: absolute; right: 20px; top: 50%; transform: translateY(-50%) translateX(6px);
  white-space: nowrap; font-size: .72rem; letter-spacing: .06em;
  color: var(--text-dim); opacity: 0; pointer-events: none;
  transition: opacity .3s var(--ease), transform .3s var(--ease);
}
.dots a:hover span { opacity: 1; transform: translateY(-50%) translateX(0); }

@media (max-width: 860px), (max-height: 640px) { .dots { display: none; } }

/* ==========================================================================
   Shader stage

   A single fixed canvas behind every panel, driven by scroll position, so the
   background is continuous while the content is paginated. It replaces the
   blurred CSS blobs when WebGL is available — `.has-stage` is set by the
   script only once a context and program actually exist, so a failed init
   silently leaves the CSS ambient in place rather than a black screen.
   ========================================================================== */
.stage {
  position: fixed; inset: 0; z-index: -2;
  width: 100%; height: 100%;
  display: block;
  opacity: 0;
  transition: opacity 1.2s var(--ease);
  pointer-events: none;
}
.has-stage .stage { opacity: 1; }
.has-stage .ambient { opacity: 0; transition: opacity 1.2s var(--ease); }

/* Keeps text legible over the brightest part of the shader without dulling it
   everywhere — a flat scrim would flatten the whole effect. */
.has-stage .panel::before {
  content: "";
  position: absolute; inset: 0; z-index: -1;
  background: radial-gradient(120% 90% at 50% 50%, rgba(13,11,26,.36), rgba(13,11,26,.72));
  pointer-events: none;
}

/* Vertical rhythm inside a deck. The spacing that gives a section room to
   breathe on a long scrolling page is exactly what pushes it past the fold
   when every section has to fit a single screen, so it tightens here — and
   scales with viewport *height*, which is the axis that actually constrains
   a panel. */
.panel .section-head { margin-bottom: clamp(18px, 3.2vh, 44px); }
.panel .grid  { gap: clamp(12px, 1.9vh, 20px); }
.panel .card  { padding: clamp(18px, 2.7vh, 28px); }
.panel .card .ico { width: 40px; height: 40px; margin-bottom: clamp(8px, 1.4vh, 17px); }

/* Short viewports. A deck asks every section to fit one screen, so the pieces
   that *can* scale — device frames, tile sizes, gaps — track viewport height
   instead of a fixed size. Without this the taller sections overhang by a few
   hundred pixels on a laptop, which reads as a broken page rather than as a
   deliberate extra scroll. */
@media (max-height: 900px) {
  .panel .phone { width: min(300px, 34vh, 78vw); }
  .panel .swatches { grid-template-columns: repeat(auto-fill, minmax(108px, 1fr)); gap: 10px; }
  .panel .slab { padding: clamp(14px, 2.2vh, 30px); }
  .panel .screens { gap: clamp(18px, 2.8vw, 44px); }
  .panel .shot-cap { margin-top: clamp(10px, 1.8vh, 20px); }
  .panel .shot-cap p { font-size: .86rem; }
  .panel .split ul { margin-top: clamp(12px, 2vh, 24px); gap: clamp(8px, 1.5vh, 15px); }
  /* Six cards stack two rows deep, so every line saved counts twice. */
  .panel .card h3 { font-size: 1rem; margin-bottom: 6px; }
  .panel .card p  { font-size: .88rem; line-height: 1.5; }
}

/* =============================================================================
   Polish

   The techniques here are the ones that carry their weight: a kinetic headline,
   pointer-reactive surfaces, and a sheen on the accent word. Deliberately no
   custom cursor and no magnetic buttons — both read as showreel rather than
   product, and both fight with real pointing.
   ============================================================================= */

/* Kinetic headline ---------------------------------------------------------
   Words rise out of a mask, each one a beat behind the last and sharpening as
   it arrives. The blur is what separates this from a plain slide-up: type that
   resolves into focus reads as depth of field, so the line feels like it is
   settling into place rather than being pushed there.

   `.w` is the mask, `<i>` the thing that moves — a single element cannot both
   clip and be clipped. Padding opens the mask enough for descenders and the
   blur radius, and equal negative margin cancels it, so splitting a headline
   never shifts the layout. */
.kinetic .w {
  display: inline-block;
  overflow: hidden;
  padding: .1em .06em .18em;
  margin: -.1em -.06em -.18em;
}
.kinetic .w > i {
  display: inline-block;
  font-style: normal;
  opacity: 0;
  transform: translate3d(0, 108%, 0) rotate(3deg);
  filter: blur(12px);
  transition:
    transform .95s var(--ease),
    filter    .8s  var(--ease),
    opacity   .55s var(--ease);
  transition-delay: calc(var(--w) * 65ms + var(--delay, 0s));
}
.panel.is-active .kinetic .w > i,
.kinetic.in .w > i { opacity: 1; transform: none; filter: blur(0); }

/* The headline animates per word, so the block-level reveal must not also fade
   it — two overlapping transitions on the same text is what makes a reveal
   look cheap. */
h1.kinetic.reveal { opacity: 1; transform: none; }

/* Accent sheen. Slow enough to read as material rather than as a loading bar. */
h1 .grad {
  background-size: 220% 100%;
  animation: sheen 9s ease-in-out infinite;
}
@keyframes sheen {
  0%, 100% { background-position: 0% 0; }
  50%      { background-position: 100% 0; }
}

/* Pointer spotlight --------------------------------------------------------
   A soft highlight tracks the cursor across a surface, so the card reads as
   something being lit rather than something being hovered. `--mx`/`--my` are
   set by the script; the fallback centre keeps it sane before first move. */
.card, .app {
  position: relative;
  isolation: isolate;
}
.card::after, .app::after {
  content: "";
  position: absolute; inset: 0; z-index: -1;
  border-radius: inherit;
  pointer-events: none;
  background: radial-gradient(320px circle at var(--mx, 50%) var(--my, 50%),
              rgba(255,255,255,.10), transparent 62%);
  opacity: 0;
  transition: opacity .4s var(--ease);
}
.card:hover::after, .app:hover::after { opacity: 1; }

/* Button shimmer. Fires on hover only — a looping shine on a call to action
   is an advert for itself. */
.btn-primary { position: relative; overflow: hidden; }
.btn-primary > * { position: relative; z-index: 1; }
.btn-primary::after {
  content: "";
  position: absolute; inset: 0;
  background: linear-gradient(105deg, transparent 34%, rgba(255,255,255,.38) 50%, transparent 66%);
  transform: translateX(-120%);
}
.btn-primary:hover::after { animation: shimmer 1.05s var(--ease); }
@keyframes shimmer { to { transform: translateX(120%); } }

/* Cross-document transitions. Progressive: browsers without the API simply
   navigate as before, so this costs nothing where it is unsupported. */
@view-transition { navigation: auto; }

@media (prefers-reduced-motion: reduce) {
  .kinetic .w > i { opacity: 1 !important; transform: none !important; filter: none !important; }
  h1 .grad { animation: none; }
  .btn-primary:hover::after { animation: none; }
  .card::after, .app::after { display: none; }
  @view-transition { navigation: none; }
}

/* =============================================================================
   No-shader fallback

   WebGL is not guaranteed: hardware acceleration gets switched off, GPUs get
   blocklisted by driver version, and some corporate profiles disable it
   outright. When that happens `.has-stage` is never set and the page falls
   back to the CSS gradient — which was *smooth*, so the texture simply
   vanished and the page looked flat rather than degraded.

   These rules give the fallback its own grain, so the difference between the
   GPU path and the CSS path is fidelity rather than presence.
   ============================================================================= */
html:not(.has-stage) .ambient span { opacity: .62; filter: blur(75px); }

/* A fourth, slower layer. Three blobs on a dark field read as three blobs;
   a fourth crossing them at a different rate reads as depth. */
html:not(.has-stage) .ambient::before {
  content: "";
  position: absolute; inset: -20%;
  background:
    radial-gradient(38% 44% at 68% 28%, rgba(153,92,235,.34), transparent 70%),
    radial-gradient(44% 38% at 24% 72%, rgba(74,58,224,.32), transparent 70%);
  filter: blur(60px);
  animation: drift-a 44s var(--ease) infinite alternate;
}

/* Grain. `feTurbulence` rasterised once into a tile, then repeated — the same
   job the shader's dither does: it breaks up the banding that an 8-bit ramp
   shows across a slow dark gradient, and adds the texture that makes a flat
   fill look like a surface. A data: URI, which the CSP's `img-src 'self'
   data:` already allows, so no extra request and no policy change. */
html:not(.has-stage) .ambient::after {
  content: "";
  position: fixed; inset: 0;
  pointer-events: none;
  opacity: .16;
  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.9' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='180' height='180' filter='url(%23n)'/%3E%3C/svg%3E");
}

@media (prefers-reduced-motion: reduce) {
  html:not(.has-stage) .ambient::before { animation: none; }
}
