/* ============================================================
   speaklangu.com

   The canvas artboards are 1440px compositions with everything placed by
   absolute coordinate, which is what an artboard is for and exactly what a
   site must not be: most of the traffic to an app's landing page is a phone.
   So this is the same design laid out as flow — one centred column in a hero
   that fills the window, and a footer under it — with the type and the orb on
   clamp() so the whole scene scales rather than being cut off or shrunk to
   nothing. The colours, the sky cycle, the grain arithmetic and the card's
   silhouette are the artboard's, unchanged.

   Palette: Night. base #020A10, accent #A98BC4, ink #FDFCFA.

   Two grounds. The default cross-fades four photographs under a stack of
   gradients; `.bg-app` shows one photograph at the strength the app shows
   it, drifting. See "the ground" and "the app's ground" below.
   ============================================================ */

/* ---------- the time of day ----------
   With the app's ground on, the page wears the palette the app would be
   wearing at this hour: its photograph, its base, and Langu in its own
   colours. `src/theme/palettes.ts` is the source for every hex here — the orb
   trio is that palette's `orb[0..2]`, and the blob field reads them by index,
   so the site and the app draw the same creature in the same light.

   The two veils are measured, not chosen, and they differ because the phone
   and the desktop see different crops of the same photograph:

     --screen-wide   the landscape crop under a 116px headline, 3:1 bar
     --screen-tall   the portrait crop under a 44px headline, same bar
     --foot-top      what the footer's band needs for a 13px legal line at
                     70% ink, 4.5:1 bar

   Morning is the palest photograph in the set and sets the budget, exactly as
   this repo's own notes say it does: it needs 0.42 where Night needs 0.14.
   Re-measure against Morning before lowering any of them. */
:root {
  --ink: #FDFCFA;

  /* Night, 20:00-05:00 — also what version A always wears */
  --base: #020A10;
  --base-rgb: 2 10 16;
  --orb0: #C6A2DC; --orb1: #8560C8; --orb2: #42388F;
  --accent: #A98BC4;
  --photo: url(assets/night.jpg);
  --photo-wide: url(assets/night-wide.jpg);
  --screen-tall: 0.14; --screen-wide: 0.14; --foot-top: 0.48;
  --mark-tint: rgba(198, 162, 220, 0.42);
  --flame: rgba(133, 96, 200, 1);
  --wash: radial-gradient(closest-side, rgba(133, 96, 200, 0.26), rgba(133, 96, 200, 0));
  --bloom: radial-gradient(closest-side, rgba(133, 96, 200, 0.46), rgba(133, 96, 200, 0));
  --faint: radial-gradient(closest-side, rgba(169, 139, 196, 0.16), rgba(169, 139, 196, 0));
  --grain: url(assets/grain.png);
  --ease: cubic-bezier(0.22, 0.9, 0.24, 1);

}

.t-dawn {
  --base: #0A0C11; --base-rgb: 10 12 17;
  --orb0: #FFD3A0; --orb1: #F09267; --orb2: #A8506E;
  --accent: #E8B98A;
  --photo: url(assets/dawn.jpg); --photo-wide: url(assets/dawn-wide.jpg);
  --screen-tall: 0.30; --screen-wide: 0.18; --foot-top: 0.50;
  --mark-tint: rgba(255, 211, 160, 0.42);
}
.t-morning {
  --base: #2C3B3F; --base-rgb: 44 59 63;
  --orb0: #EAF2F8; --orb1: #8FB6D0; --orb2: #3E7CA8;
  --accent: #BFD2DA;
  --photo: url(assets/morning.jpg); --photo-wide: url(assets/morning-wide.jpg);
  --screen-tall: 0.48; --screen-wide: 0.48; --foot-top: 0.82;
  --mark-tint: rgba(234, 242, 248, 0.42);
}
.t-afternoon {
  --base: #313438; --base-rgb: 49 52 56;
  --orb0: #B8E6DC; --orb1: #41BFC4; --orb2: #1E7A9E;
  --accent: #8CC8C8;
  --photo: url(assets/afternoon.jpg); --photo-wide: url(assets/afternoon-wide.jpg);
  --screen-tall: 0.14; --screen-wide: 0.26; --foot-top: 0.60;
  --mark-tint: rgba(184, 230, 220, 0.42);
}
.t-dusk {
  --base: #22130A; --base-rgb: 34 19 10;
  --orb0: #F5B98A; --orb1: #E8735E; --orb2: #A03C6E;
  --accent: #E8A97E;
  --photo: url(assets/dusk.jpg); --photo-wide: url(assets/dusk-wide.jpg);
  --screen-tall: 0.34; --screen-wide: 0.36; --foot-top: 0.66;
  --mark-tint: rgba(245, 185, 138, 0.42);
}
.t-night {
  --base: #020A10; --base-rgb: 2 10 16;
  --orb0: #C6A2DC; --orb1: #8560C8; --orb2: #42388F;
  --accent: #A98BC4;
  --photo: url(assets/night.jpg); --photo-wide: url(assets/night-wide.jpg);
  --screen-tall: 0.14; --screen-wide: 0.14; --foot-top: 0.48;
  --mark-tint: rgba(198, 162, 220, 0.42);
}

/* ---------- the second artwork set ----------
   `<html class="art-places">`, set by the script in the page head from
   `?art=places` and remembered, the same way `?bg=` already works.

   It overrides two variables per theme and nothing else: the palette, the orb
   colours, the scrim strengths and the accent all stay exactly as they are,
   because this is a change of photograph and not of palette. That is also
   what makes going back free — drop the class and the old art is still there.

   The app has the same switch under You → Background artwork, and the two
   sets are generated by the same script (`npm run backgrounds -- --set
   places`), so a picture that ships here ships there. */
.art-places.t-dawn      { --photo: url(assets/places/dawn.jpg);      --photo-wide: url(assets/places/dawn-wide.jpg); }
.art-places.t-morning   { --photo: url(assets/places/morning.jpg);   --photo-wide: url(assets/places/morning-wide.jpg); }
.art-places.t-afternoon { --photo: url(assets/places/afternoon.jpg); --photo-wide: url(assets/places/afternoon-wide.jpg); }
.art-places.t-dusk      { --photo: url(assets/places/dusk.jpg);      --photo-wide: url(assets/places/dusk-wide.jpg); }
.art-places.t-night     { --photo: url(assets/places/night.jpg);     --photo-wide: url(assets/places/night-wide.jpg); }


* { box-sizing: border-box; }

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

html, body {
  /* the page does not rubber-band past its own ends, and a scroll that reaches
     the end of the contact panel does not chain out to the page behind it */
  overscroll-behavior: none;
}

body {
  margin: 0;
  background: var(--base);
  color: var(--ink);
  font-family: Inter, system-ui, -apple-system, sans-serif;
  -webkit-font-smoothing: antialiased;
  overflow-x: hidden;
  /* Safari and Chrome on Android paint a grey box over whatever you touch.
     It is drawn on the element's BOX, so on a pill it is a rectangle around
     a rounded button and on the orb it is a square — always the wrong shape.
     The focus ring stays; that one is for the keyboard and is drawn to fit. */
  -webkit-tap-highlight-color: transparent;
}
a, button, input, textarea, label, [role="button"] { -webkit-tap-highlight-color: transparent; }

.serif { font-family: 'Instrument Serif', Georgia, serif; font-weight: 400; }
.halo { text-shadow: 0 1px 10px rgba(0, 0, 0, 0.45); }
a { color: var(--ink); text-decoration: none; }

.sr-only {
  position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px;
  overflow: hidden; clip-path: inset(50%); white-space: nowrap;
}

/* ---------- the ground ----------
   Four photographs cross-fading on one 26s clock, with the orb's hue turning on
   the same clock so the light in the scene and the light in the orb agree. The
   ground is fixed: the page scrolls over it, which is how the app's backdrop
   behaves and what keeps a tall legal page from stretching one photograph. */

/* `height: 100lvh`, not `inset: 0`. A fixed box sized to `inset: 0` is the
   SMALL viewport on iOS — the one with Safari's toolbars showing. Retract
   them and the page grows, the scrolling content fills the strip that opens
   up, and the photograph does not reach it. That is the flat band at the top
   with type sitting on it. `lvh` is the tall state, so the ground covers
   every state the page can be in. */
.ground {
  position: fixed; left: 0; right: 0; top: 0; height: 100lvh;
  z-index: 0; pointer-events: none;
}

.ground .shot {
  position: absolute; inset: 0;
  background-size: cover;
  background-position: center 42%;
}
.shot-0 { background-image: url(assets/night-wide.jpg); }
.shot-1 { background-image: url(assets/dusk-wide.jpg); }
.shot-2 { background-image: url(assets/rose-wide.jpg); }
.shot-3 { background-image: url(assets/afternoon-wide.jpg); }

@media (prefers-reduced-motion: no-preference) {
  .shot-0 { animation: sky0 26s linear infinite; }
  .shot-1 { animation: sky1 26s linear infinite; }
  .shot-2 { animation: sky2 26s linear infinite; }
  .shot-3 { animation: sky3 26s linear infinite; }
}
@media (prefers-reduced-motion: reduce) {
  .shot-1, .shot-2, .shot-3 { opacity: 0; }
}
@keyframes sky0 { 0%, 20.83% { opacity: 1 } 33.33%, 91.67% { opacity: 0 } 100% { opacity: 1 } }
@keyframes sky1 { 0%, 16.67% { opacity: 0 } 29.17%, 45.83% { opacity: 1 } 58.33%, 100% { opacity: 0 } }
@keyframes sky2 { 0%, 41.67% { opacity: 0 } 54.17%, 70.83% { opacity: 1 } 83.33%, 100% { opacity: 0 } }
@keyframes sky3 { 0%, 66.67% { opacity: 0 } 79.17%, 95.83% { opacity: 1 } 100%, 100% { opacity: 0 } }

/* the sky gradient and the veil, then the dark the headline needs around it */
.ground .sky {
  position: absolute; inset: 0;
  background: linear-gradient(180deg,
    rgba(2, 10, 16, 0.7) 0%, rgba(2, 10, 16, 0.25) 30%,
    rgba(2, 10, 16, 0.2) 58%, rgba(2, 10, 16, 0.85) 86%, var(--base) 100%);
}
.ground .sink {
  position: absolute; inset: 0; opacity: 0.42;
  background: linear-gradient(180deg, var(--base) 0%, rgba(2, 10, 16, 0.42) 26%,
    rgba(2, 10, 16, 0.36) 62%, var(--base) 100%);
}
.ground .dim { position: absolute; inset: 0; background: rgba(2, 10, 16, 0.4); }
.ground .core {
  position: absolute; left: 50%; top: 50%;
  width: min(1700px, 150vw); height: min(1100px, 115vh);
  transform: translate(-50%, -50%);
  background: radial-gradient(ellipse closest-side,
    rgba(0, 0, 0, 0.62), rgba(0, 0, 0, 0.34) 54%, rgba(0, 0, 0, 0) 80%);
}
/* a photograph that runs to a hard edge announces that the page is made of
   slabs; the foot resolves into the page's own ground instead */
.ground .base-foot {
  position: absolute; left: 0; right: 0; bottom: 0; height: 36vh;
  background: linear-gradient(to bottom, rgba(0, 0, 0, 0), rgba(2, 10, 16, 0.62));
}
.ground .lamp {
  position: absolute; left: 50%; top: 50%;
  width: min(1200px, 150vw); aspect-ratio: 1;
  transform: translate(-50%, -50%);
  border-radius: 50%; background: var(--wash); opacity: 0.4;
}
@media (prefers-reduced-motion: no-preference) {
  .ground .lamp { animation: lampbreath 17s ease-in-out infinite; }
}
@keyframes lampbreath {
  0%, 100% { transform: translate(-50%, -50%) scale(1); opacity: 0.3 }
  50% { transform: translate(-50%, -50%) scale(1.1); opacity: 0.54 }
}

/* ---------- the app's ground ----------
   `.bg-app` is the second version of this page, and the difference is a
   decision rather than a colour scheme: the default buries the photograph
   under six gradient layers, and this shows it the way the app does —
   `Backdrop.tsx`, one photograph at full strength with a 48s Ken Burns and
   the palette's own `base` mixed over it at `1 - intensity`.

   The intensity is measured, not chosen. At full strength the 98th-percentile
   luminance under the headline (it lands on the aurora band) is 0.312, which
   puts white type at 2.90:1 — under the 3:1 a 116px headline needs. Mixing
   0.18 of base over it takes that to 4.07:1 and costs almost nothing of the
   picture. The contact state carries a column of 17px prose, so it drops to
   the app's own `TEXT_SCREEN` of 0.44 — 7.04:1 — which is the same rule
   `Screen.tsx` applies to the text-dense screens.

   A phone gets the portrait crop, the same choice `WIDE_ASPECT` makes in the
   app. `cover` on a landscape file in a portrait window shows about a fifth
   of the frame at 2.3x, which is the band this repo already documents. */

.groundapp {
  position: fixed; left: 0; right: 0; top: 0; height: 100lvh;
  z-index: 0; pointer-events: none; background: var(--base); overflow: hidden;
}
.groundapp .kb {
  position: absolute; inset: 0;
  background-image: var(--photo);
  background-size: cover; background-position: center;
}
/* the portrait crop on a portrait window, which is the choice `WIDE_ASPECT`
   makes in the app; the veil changes with it, because the two crops put
   different parts of the same sky under the headline */
@media (min-aspect-ratio: 115 / 100) {
  .groundapp .kb { background-image: var(--photo-wide); }
}
@media (prefers-reduced-motion: no-preference) {
  .groundapp .kb { animation: kenburns 48s ease-in-out infinite alternate; }
}
@keyframes kenburns {
  from { transform: scale(1.08) }
  to { transform: scale(1.14) translate(14px, -22px) }
}
.groundapp .screen {
  position: absolute; inset: 0; background: var(--base);
  opacity: var(--screen-tall); transition: opacity 0.85s ease;
}
/* after the rule above, not before it: both are two classes, so at equal
   specificity the later one wins and a media query written above this would
   silently do nothing */
@media (min-aspect-ratio: 115 / 100) {
  .groundapp .screen { opacity: var(--screen-wide); }
}
/* The contact panel is a column of small prose, so it takes the app's own
   `TEXT_SCREEN` — 0.44 of artwork, a 0.56 veil — plus a little more. The app
   spends that allowance on lists that sit on `onArt` surfaces; this blurb
   floats on the photograph with nothing but a halo under it, and measured
   against the render at 0.56 it came out at 3.39:1 on Morning and 3.51 on
   Morning's phone crop, under the 4.5 a 15px line needs. 0.64 with the blurb
   at full ink clears it on all five. */
body.contact-live .groundapp .screen { opacity: 0.64; }

/* the two grounds are both in the markup; the body class picks one */
.groundapp { display: none; }
.bg-app .ground { display: none; }
.bg-app .groundapp { display: block; }
/* The panel floats straight on the photograph now that it has no card of its
   own, so it needs its own pool of dark — which is what the gradient ground's
   veil always did, in the palette's base rather than a fixed near-black.
   Measured without it on the render: Morning's placeholder came out at 2.98:1
   and its border at 2.89, against 4.5 and 3.0. It is deliberately off-centre
   and falls away to nothing, so the right of the frame keeps the photograph at
   full strength and Langu stays in it. A phone has no room for a pool: there
   the sheet is the whole screen and the photograph steps out entirely. */
.bg-app .contactveil {
  background: radial-gradient(ellipse 820px 760px at 26% 50%,
    rgb(var(--base-rgb) / 0.78) 0%, rgb(var(--base-rgb) / 0.58) 48%,
    rgb(var(--base-rgb) / 0.16) 76%, rgb(var(--base-rgb) / 0) 100%);
}
/* on a phone the panel is the whole width, so the pool is a wash rather than
   an off-centre ellipse; the photograph still reads through it */
@media (max-width: 900px) {
  .bg-app .contactveil {
    background: linear-gradient(180deg,
      rgb(var(--base-rgb) / 0.5) 0%, rgb(var(--base-rgb) / 0.72) 26%,
      rgb(var(--base-rgb) / 0.72) 74%, rgb(var(--base-rgb) / 0.5) 100%);
  }
}

/* ---------- the film ----------
   Two passes, because one cannot cover the range: overlay is inert as the base
   approaches either end, so over a near black ground it does nothing and the
   flat veil is what lands. Painted last, over the whole page, because
   everything in one exposure is the look.

   **A blend layer may not sit inside a positioned wrapper.** Anything that
   forms a stacking context is also a blending isolation group, so a child with
   mix-blend-mode inside one blends against transparent black rather than
   against the page — and an overlay pass against nothing composites as the
   grey tile itself. Measured: it lifted a #020A10 footer to rgb(103,104,105).
   These are siblings at the body level for that reason, each its own layer.

   Two things now sit ABOVE it and carry no grain: Langu, and the contact
   card. Both are objects rather than surfaces — the orb is the one luminous
   thing on the page and the card is a lit panel — and grain on either read as
   dirt on the lens rather than as film. Everything else still gets it. The
   arithmetic is in "the layer order" below. */
.film {
  position: fixed; inset: 0; z-index: 90; pointer-events: none; display: block;
  background-image: var(--grain);
  background-repeat: repeat;
  background-size: 256px 256px;
}
.film.over { mix-blend-mode: overlay; opacity: 0.4; }
.film.veil { opacity: 0.055; }

/* ---------- the ends of the screen ----------
   The strip a phone's status bar and home indicator sit over, reserved by the
   page because the page is drawn underneath them.

   **`clamp`, because the inset is a hint and it fails in both directions.** It
   is zero until a page is installed to the home screen, so a bare `env()`
   reserves nothing and the clock sits on the masthead; and Chrome for Android
   with the address bar at the bottom reports far more than a status bar's
   worth, so a floor with no ceiling takes a black band out of the screen. 30
   and 40 are the two numbers to move if the band is ever wrong again.

   The height is a *multiple* of the strip, not the strip plus a fade, so a
   strip of zero is a height of zero and a desktop window paints nothing. */
:root { --top-strip: 0px; --bot-strip: 0px; }
@media (max-width: 900px) {
  :root {
    --top-strip: clamp(30px, env(safe-area-inset-top, 0px), 40px);
    --bot-strip: clamp(18px, env(safe-area-inset-bottom, 0px), 26px);
  }
}

.topguard, .botguard {
  position: fixed; left: 0; right: 0; z-index: 110;
  pointer-events: none;
}
.topguard {
  top: 0; height: calc(var(--top-strip) * 1.15);
  background: linear-gradient(to bottom,
    var(--base) 0, var(--base) var(--top-strip), rgb(var(--base-rgb) / 0) 100%);
}
.botguard {
  bottom: 0; height: calc(var(--bot-strip) * 1.15);
  background: linear-gradient(to top,
    var(--base) 0, var(--base) var(--bot-strip), rgb(var(--base-rgb) / 0) 100%);
}

/* ---------- masthead ---------- */

/* **The masthead has to clear the guard, not just the strip.** It is the one
   thing painted *above* it — `z-index: 120` against 110 — so it is the one
   thing the guard cannot cover, and at `strip + a little` against a guard of
   `strip + a bit more` the wordmark sits inside the band and paints on top of
   it. The gap here is the larger of the two on purpose.

   `max`, so a desktop window is untouched: `--top-strip` is 0 there and this
   is the clamp it has always been. */
.nav {
  position: relative; z-index: 120;
  display: flex; align-items: center; justify-content: space-between;
  padding:
    max(clamp(24px, 3.2vw, 46px), calc(var(--top-strip) + 14px))
    calc(clamp(22px, 5vw, 72px) + env(safe-area-inset-right, 0px))
    0
    calc(clamp(22px, 5vw, 72px) + env(safe-area-inset-left, 0px));
}
.nav .wordmark { font-size: clamp(22px, 1.9vw, 27px); line-height: 1; letter-spacing: -0.012em; }
.nav .links { position: relative; display: inline-flex; align-items: center; }

.navlink {
  background: none; border: 0; padding: 0; font: inherit; cursor: pointer;
  display: inline-flex; align-items: center; justify-content: center; min-height: 44px;
  font-size: clamp(13px, 1.05vw, 15px); font-weight: 500; letter-spacing: -0.005em;
  color: rgba(253, 252, 250, 0.72);
  transition: color 0.4s var(--ease), opacity 0.3s ease, visibility 0s;
}
.navlink:hover, .navlink:focus-visible { color: var(--ink); }

/* Contact and the close share the corner: one control that changes meaning in
   place rather than a second one appearing somewhere else. */
.navclose {
  position: absolute; right: -10px; top: 50%; margin-top: -22px;
  width: 44px; height: 44px; display: inline-flex; align-items: center; justify-content: center;
  padding: 0; border: 0; background: none; cursor: pointer;
  color: rgba(253, 252, 250, 0.72);
  opacity: 0; visibility: hidden; pointer-events: none;
  transition: opacity 0.3s ease, visibility 0s linear 0.3s, color 0.3s ease, transform 0.4s var(--ease);
}
.navclose:hover, .navclose:focus-visible { color: var(--ink); transform: scale(1.12); }
body.contact-live .navopen { opacity: 0; visibility: hidden; pointer-events: none; transition-delay: 0s, 0.3s; }
body.contact-live .navclose {
  opacity: 1; visibility: visible; pointer-events: auto;
  transition: opacity 0.3s ease 0.22s, visibility 0s, color 0.3s ease, transform 0.4s var(--ease);
}

/* ---------- the hero ----------
   One screen. The sentence with a hole in it, and Langu resting in the hole.

   `.frame` is exactly the window, which is the whole of why no part of the
   footer is in view before you scroll. It used to be `body { min-height:
   100svh }` with the footer inside the same column — and the footer's
   wordmark alone is over 400px tall, so on any ordinary laptop the sum
   overflowed the window and the top of the band showed under the pill. */

.frame {
  position: relative;
  min-height: 100svh;
  display: flex; flex-direction: column;
}

.hero {
  flex: 1 1 auto; overflow-x: clip;
  display: grid; place-items: center;
  padding:
    clamp(30px, 5vh, 70px)
    calc(22px + env(safe-area-inset-right, 0px))
    clamp(40px, 6vh, 80px)
    calc(22px + env(safe-area-inset-left, 0px));
}

/* Both are capped by the window's HEIGHT as well as its width, and that is
   not a refinement — sized on width alone the scene is 766pt tall in a
   1280x720 laptop window and the App Store button is off the bottom of the
   screen. A hero that fills the window has to be measured against the window
   it fills, in both directions. */
.say, .orbslot {
  --h1: clamp(38px, min(11.2vw, 13svh), 116px);
  --orb: clamp(200px, min(50vw, 34svh), 300px);
  --air: clamp(4px, 0.6vh, 12px);
}

/* one grid cell holding both, so the orb can be positioned as if it were
   inside the sentence while living outside its stacking context */
.cell { display: grid; }
.say {
  grid-area: 1 / 1; position: relative; z-index: 10;
  display: flex; flex-direction: column; align-items: center;
}
.orbslot { grid-area: 1 / 1; position: relative; z-index: 96; pointer-events: none; }

.say h1, .say .pill {
  transition: opacity 0.42s ease, transform 0.7s var(--ease), visibility 0s;
}
body.contact-live .say h1,
body.contact-live .say .pill {
  opacity: 0; visibility: hidden; pointer-events: none;
  transform: translate3d(0, -32px, 0);
  transition: opacity 0.42s ease, transform 0.7s var(--ease), visibility 0s linear 0.42s;
}

.hero h1 {
  margin: 0; padding: 0; text-align: center; color: var(--ink);
  font-size: var(--h1); line-height: 0.92; letter-spacing: -0.04em;
}
.hero h1 .gap { display: block; height: calc(var(--orb) + var(--air) * 2); }
.hero h1 em { font-style: italic; }
.hero h1 span { display: block; }

/* ---------- Langu ----------
   Drawn rather than loaded. It was `orb.png`, which is a picture of a face
   and so a face that cannot close its eyes; this is the app's own creature —
   `ORB_BLOBS`, `GOO_*` and `FACE` out of `src/theme/orbShape.ts`, at D = 216
   inside the 364 canvas `ORB_SIZE + BLOOM_PADDING * 2` gives. Six blobs
   blurred, pushed through an alpha ramp and blurred again, so the silhouette
   is emergent and deforms because the blobs are actually moving. */
.orb {
  position: absolute; left: 50%; top: calc(var(--h1) * 0.92 + var(--air));
  width: var(--orb); height: var(--orb);
  transform: translateX(-50%);
  transition: transform 1.05s var(--ease), opacity 0.6s ease;
}
.orb svg { display: block; }
@media (prefers-reduced-motion: no-preference) {
  .orb svg { animation: float 9s ease-in-out infinite, turn 26s linear infinite; }
  .orb .bl {
    transform-box: view-box;
    animation-name: wander; animation-timing-function: ease-in-out;
    animation-iteration-count: infinite;
  }
  .orb .eye { animation: blink 7.4s linear infinite; }
}
/* with the app's ground the sky no longer cycles, so neither does the orb */
.bg-app .orb svg { animation: float 9s ease-in-out infinite; }

@keyframes float { 0%, 100% { transform: translate3d(0, 0, 0) } 50% { transform: translate3d(0, -16px, 0) } }
@keyframes turn {
  0% { filter: none }
  25% { filter: hue-rotate(108deg) saturate(1.05) }
  50% { filter: hue-rotate(90deg) saturate(1.05) }
  75% { filter: hue-rotate(-78deg) saturate(1.2) brightness(1.28) }
  100% { filter: none }
}

/* The hues are `ORB_BLOBS`' own `hue` indexes into `palette.orb`, so Langu
   changes colour with the hour exactly as the app's orb changes with the
   palette. `orb[3]` is unused here for the reason orbShape.ts gives: as a
   full blob the darkest colour lands as a patch that reads as a dent.
   The rim blobs paint over the centre one, which is why the middle of the
   orb is `orb[2]` — the deepest colour — and why the face's white sits on it. */
.orb .b0 { fill: var(--orb0); --d: 6.5; animation-duration: 11.4s; }
.orb .b1 { fill: var(--orb2); --d: 10.8; animation-duration: 8.6s; animation-delay: -2.3s; }
.orb .b2 { fill: var(--orb1); --d: 9.7; animation-duration: 10.3s; animation-delay: -4.6s; }
.orb .b3 { fill: var(--orb2); --d: 11.9; animation-duration: 7.5s; animation-delay: -6.8s; }
.orb .b4 { fill: var(--orb1); --d: 10.8; animation-duration: 9.2s; animation-delay: -3.1s; }
.orb .b5 { fill: var(--orb2); --d: 9.7; animation-duration: 8s; animation-delay: -5.6s; }
.orb .bloomstop { stop-color: var(--accent); }
.orb .eye { fill: var(--ink); }
.orb .smile { fill: none; stroke: var(--ink); }
@keyframes wander {
  0% { transform: translate(calc(var(--d) * 1px), 0) }
  25% { transform: translate(0, calc(var(--d) * 1px)) }
  50% { transform: translate(calc(var(--d) * -1px), 0) }
  75% { transform: translate(0, calc(var(--d) * -1px)) }
  100% { transform: translate(calc(var(--d) * 1px), 0) }
}

/* The blink is `PetFace`'s own curve rather than a fade: 200ms all told, 35%
   of it closing, 25% held shut, 40% opening — and a double blink now and
   then, because a metronome does not read as alive. A pill scaled to 0.04
   keeps its round ends, so what is drawn at the bottom of a blink is a closed
   lid rather than nothing. */
.orb .eye { transform-box: fill-box; transform-origin: center; }
@keyframes blink {
  0%, 57.9% { transform: scaleY(1) }
  58.9%, 59.6% { transform: scaleY(0.04) }
  60.7%, 90% { transform: scaleY(1) }
  91%, 91.7% { transform: scaleY(0.04) }
  92.8%, 94% { transform: scaleY(1) }
  95%, 95.7% { transform: scaleY(0.04) }
  96.8%, 100% { transform: scaleY(1) }
}

.pill {
  margin-top: clamp(26px, 3.4vh, 44px);
  display: inline-flex; align-items: center; gap: 11px;
  height: 54px; padding: 0 28px; border-radius: 12px;
  background: var(--ink); color: var(--base);
  font-size: 15px; font-weight: 600; letter-spacing: -0.01em;
  transition: transform 0.5s cubic-bezier(0.2, 0.8, 0.2, 1);
}
.pill:hover, .pill:focus-visible { transform: translate3d(0, -3px, 0); }

/* ---------- contact ----------
   Pressing Contact hands the hero over to a way of reaching us: Langu slides
   aside and keeps the frame company, the sentence steps out rather than being
   covered up, and the panel comes in from the left. */

body.contact-live .orb {
  transform: translateX(-50%) translate3d(min(30vw, 400px), -4vh, 0) scale(0.82);
}

.contactveil {
  position: fixed; inset: 0; z-index: 5; pointer-events: none;
  opacity: 0; transition: opacity 0.85s ease;
  background: radial-gradient(ellipse 920px 760px at 30% 52%,
    rgba(3, 6, 11, 0.88) 0%, rgba(3, 6, 11, 0.66) 52%,
    rgba(3, 6, 11, 0.18) 78%, rgba(3, 6, 11, 0) 100%);
}
body.contact-live .contactveil { opacity: 1; }

.contact {
  position: absolute; top: 50%; z-index: 95;
  left: max(72px, calc(50vw - 600px));
  /* narrower than it was, because every control now sits on one measure and a
     620px-wide button is not a button */
  width: min(460px, calc(100vw - 44px));
  transform: translate3d(-46px, -50%, 0);
  opacity: 0; visibility: hidden;
  transition: opacity 0.45s ease, transform 0.8s var(--ease), visibility 0s linear 0.45s;
}
body.contact-live .contact {
  opacity: 1; visibility: visible;
  transform: translate3d(0, -50%, 0);
  transition: opacity 0.5s ease 0.18s, transform 0.85s var(--ease) 0.18s, visibility 0s;
}

.contact h2 {
  margin: 0; padding: 0; color: var(--ink);
  font-size: clamp(34px, 3.6vw, 52px); line-height: 0.96; letter-spacing: -0.03em;
}
.contact h2 em { font-style: italic; }
/* The word beside the heading, instead of a paragraph under it.
 *
 * It was three lines of prose naming what you might write about — questions,
 * press, partnerships, your account — which is a list pretending to be a
 * sentence. One word at a time says the same thing in a quarter of the space
 * and gives the panel the only moving part it has.
 *
 * No script: four copies cross-fading on one clock, the same four keyframes
 * the sky already runs on. `.sizer` is an in-flow copy of the longest word,
 * hidden — it is what gives the inline-block a width and a baseline, so the
 * word sits on the heading's own baseline and nothing reflows as they change.
 */
.rota {
  position: relative; display: inline-block; vertical-align: baseline;
  margin-left: 14px;
  font-family: Inter, system-ui, -apple-system, sans-serif;
  font-size: clamp(15px, 1.2vw, 18px); font-weight: 500;
  letter-spacing: -0.005em;
  /* Ink, not `--accent`. The accent is the palette's identity and it was the
     obvious choice, but it is a mid-tone: measured on the render it came out
     at 3.21:1 on Morning, 3.50 on Dusk and 3.57 on Night against the 4.5 a
     15px line needs. `--orb0` is lighter and still missed on two of them. The
     app's own answer for small text floating on artwork with no surface under
     it is ink plus a halo, and that is what this is. */
  color: rgba(253, 252, 250, 0.88);
  text-shadow: 0 1px 10px rgba(0, 0, 0, 0.45);
}
.rota .sizer { visibility: hidden; }
.rota i {
  position: absolute; left: 0; top: 0;
  font-style: normal; white-space: nowrap; opacity: 0;
}
/* One after another, never two at once. They used to cross-fade on the sky's
   own four keyframes, which is right for photographs — a dissolve between two
   skies is a sky — and wrong for words: for a third of a second the slot held
   two of them on top of each other and neither could be read. Each now clears
   the slot before the next arrives, with a small rise into it and out of it
   so the hand-over has a direction. */
@media (prefers-reduced-motion: no-preference) {
  .rota i {
    animation: rotaword 13s cubic-bezier(0.22, 0.9, 0.24, 1) infinite;
  }
  .rota .r1 { animation-delay: -9.75s; }
  .rota .r2 { animation-delay: -6.5s; }
  .rota .r3 { animation-delay: -3.25s; }
}
@keyframes rotaword {
  0% { opacity: 0; transform: translateY(6px) }
  2%, 23% { opacity: 1; transform: translateY(0) }
  25%, 100% { opacity: 0; transform: translateY(-6px) }
}
@media (prefers-reduced-motion: reduce) { .rota .r0 { opacity: 1; } }

/* No container. The card was the app's own surface — a superellipse with
   three harmonics breathing on its rim — and it is right on the Ask screen,
   where it holds an answer. Wrapped around three inputs it was a second
   object standing between the reader and the only thing on the screen to do.
   What is left is the paywall's own field: an outline, and nothing else. */
.card { position: relative; margin-top: clamp(20px, 2.6vh, 28px); }

.card .rows { display: flex; flex-direction: column; gap: 12px; }
/* stacked, not side by side: one measure means one column */
.card .pair { display: flex; flex-direction: column; gap: 12px; }
.card .field { display: flex; flex-direction: column; }

/* The labels come off the picture and stay in the markup, which is what the
   paywall's own field does — its placeholder is the label. Three stacked
   inputs whose placeholders say Your name, you@example.com and what to write
   need no second set of words over them. */
label {
  position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px;
  overflow: hidden; clip-path: inset(50%); white-space: nowrap;
}

input, textarea {
  width: 100%; outline: none;
  background: none;
  border: 1.5px solid rgba(253, 252, 250, 0.6);
  border-radius: 6px;
  font-family: inherit; font-size: 16px; color: var(--ink);
  transition: border-color 0.3s ease;
}
input { height: 46px; padding: 0 16px; }
textarea { height: 96px; padding: 13px 16px; line-height: 20px; resize: none; }
/* 0.68, not the paywall's 0.5: its field sits on a dark screen and this one
   sits on a photograph. Measured, 0.62 left Morning's desktop placeholder at
   4.26:1 — and the placeholder is the label here, so it has to clear 4.5. */
input::placeholder, textarea::placeholder { color: rgba(253, 252, 250, 0.68); }
input:focus, textarea:focus { border-color: var(--ink); }
input:focus-visible, textarea:focus-visible { outline: 2px solid rgba(253, 252, 250, 0.66); outline-offset: 2px; }

.send-row {
  margin-top: 18px; display: flex; flex-direction: column; align-items: stretch; gap: 14px;
}
.send {
  display: inline-flex; align-items: center; justify-content: center;
  width: 100%; height: 46px; border: 0; border-radius: 6px;
  background: var(--ink); color: var(--base);
  font-family: inherit; font-size: 15px; font-weight: 600; letter-spacing: 0;
  cursor: pointer; transition: opacity 0.3s ease;
}
.send svg { display: none; }
.send:hover, .send:focus-visible { opacity: 0.88; }
.send[disabled] { opacity: 0.6; cursor: default; transform: none; }
.reach { font-size: 13.5px; line-height: 19px; color: rgba(253, 252, 250, 0.82); text-align: center; }
.mail {
  color: var(--ink); border-bottom: 1px solid rgba(253, 252, 250, 0.4);
  padding-bottom: 1px; transition: border-color 0.3s ease;
}
.mail:hover, .mail:focus-visible { border-color: var(--ink); }


.sent { display: none; align-items: flex-start; gap: 16px; }
.card.is-sent .rows, .card.is-sent + .send-row { display: none; }
.card.is-sent .sent { display: flex; }
.sent .tick {
  width: 40px; height: 40px; flex: 0 0 auto; border-radius: 40px;
  display: inline-flex; align-items: center; justify-content: center;
  background: var(--ink); color: var(--base);
}
.sent .big { font-size: clamp(30px, 3vw, 44px); line-height: 1.05; letter-spacing: -0.02em; }
.sent p { margin: 6px 0 0; font-size: 15px; line-height: 24px; color: rgba(253, 252, 250, 0.86); }

/* The cloned Langu is the phone's. On a desktop the hero's own orb is still
   on screen beside the panel, so showing this one too is two of him. */
.orbhere { display: none; }

/* ---------- contact on a phone ----------
   Part of the page, not an overlay laid over it.
   
   It used to be `position: fixed; inset: 0`, which solved the form being
   taller than the window and created a worse problem: the page behind was
   still scrollable, so scrolling the sheet ran the FOOTER up underneath it,
   showing through a panel that has no ground of its own. In flow, the frame
   simply grows to hold the panel and the footer sits after it — which is
   where a footer goes. It also means one scroll rather than two nested ones,
   and the heading can never be put out of reach, which is what `margin: auto`
   inside a flex scroller was there to prevent.
   
   The sentence and Langu's roaming copy come out while it is open; the copy
   `site.js` cloned into the panel is the one you see. */
@media (max-width: 900px) {
  .contact {
    display: none;
    /* relative, not static: in flow, but still able to carry a z-index, which
       is what keeps Langu and the fields above the film and free of grain.
       The offsets have to be cleared with it — `top: 50%` is harmless on an
       absolute box and shifts a relative one half the hero down the page. */
    position: relative; top: auto; left: auto;
    width: auto; max-width: none;
    justify-self: stretch;
    opacity: 1; visibility: visible; transform: none;
  }
  body.contact-live .contact { display: block; animation: panelin 0.5s var(--ease) both; }
  body.contact-live .cell { display: none; }
  body.contact-live .hero { padding-top: clamp(30px, 5vh, 70px); }
  .contact .panel { margin: 0; width: 100%; }

  @keyframes panelin {
    from { opacity: 0; transform: translate3d(0, 20px, 0) }
    to { opacity: 1; transform: none }
  }

  .contact h2 { font-size: 34px; }
  .orbhere { display: block; width: 104px; height: 104px; margin: 0 auto 2px; }
  /* `body.contact-live .orb` is one specificity point heavier than a bare
     `.orbhere .orb`, so without the body on the front the clone inherits the
     desktop orb's `scale(0.82)` and draws at 85 of its 104 points. */
  .orbhere .orb,
  body.contact-live .orbhere .orb {
    position: static; width: 100%; height: 100%;
    transform: none; opacity: 1;
  }

  .contactveil {
    background: linear-gradient(180deg, rgba(3, 6, 11, 0.62) 0%, rgba(3, 6, 11, 0.86) 20%,
      rgba(3, 6, 11, 0.93) 46%, rgba(3, 6, 11, 0.95) 100%);
  }
}

/* ---------- footer ----------
   The legal line at the head of the band, the wordmark set large under it and
   cropped by the foot of the page. The band carries its own ground and
   isolates, so the film below can sit under the letters instead of over the
   photograph inside them. */

/* The band is the ground continuing, not a slab laid on top of it. It used to
   be a flat `--base` rectangle, which cut the photograph dead on a hard
   horizontal line half way down the page — the one place the page announced
   it was made of parts. Now the footer is transparent and the fixed backdrop
   shows through it, resolving into the palette's own base by the time the
   wordmark starts. Two things that buys: the join with the hero is a fade
   rather than an edge, and the wordmark still sits on base, which is what
   keeps the photograph inside the letters readable. */
.foot {
  --fm: clamp(112px, 36vw, 460px);
  /* where the ground stops being the photograph and starts being the base.
     In px, not a percentage of the band: the footer is 568pt tall on a desktop
     and 262 on a phone, so the same percentage put the fade above the legal
     line on one and below it on the other. */
  --fade: clamp(64px, 7vw, 110px);
  --settle: clamp(40px, 5vw, 86px);
  position: relative; z-index: 10;
  background: none; overflow: clip;
  display: flex; flex-direction: column;
  padding:
    clamp(92px, 10vw, 150px)
    calc(clamp(22px, 6.6vw, 96px) + env(safe-area-inset-right, 0px))
    var(--bot-strip)
    calc(clamp(22px, 6.6vw, 96px) + env(safe-area-inset-left, 0px));
}
/* The band has to START at nothing, or it is the slab again with a gradient
   inside it: the ground above the footer carries `--screen` and the band
   carries `--foot-top`, and any step between the two draws a hard rule across
   the page at the footer's top edge — which is the exact line this was meant
   to remove, and it did until it was measured against Afternoon (0.26 above,
   0.60 below). So it fades in from zero, reaching full by a fifth of the way
   down; the legal line's top padding is what puts it below that fade rather
   than inside it, which is why the padding is large and not a taste call. */
.foot .band {
  position: absolute; inset: 0; z-index: 0; pointer-events: none;
  background: linear-gradient(180deg,
    rgb(var(--base-rgb) / 0) 0,
    rgb(var(--base-rgb) / var(--foot-top)) var(--fade),
    var(--base) calc(var(--fade) + var(--settle)));
}
.foot .legal {
  position: relative; z-index: 2;
  display: flex; flex-wrap: wrap; gap: 14px 24px; justify-content: space-between;
  align-items: baseline;
  font-size: 13px; line-height: 20px; color: rgba(253, 252, 250, 0.7);
}
.foot .legal a { color: rgba(253, 252, 250, 0.7); }
.foot .legal a:hover, .foot .legal a:focus-visible { color: var(--ink); }
.foot .legal .links { display: flex; gap: 24px; }
@media (max-width: 560px) {
  .foot .legal { flex-direction: column; align-items: center; text-align: center; gap: 10px; }
}

/* Instrument Serif has an ascent of 0.99em and a descent of 0.31em, so the ink
   is 1.30em and a line-height of 1.3 is exactly what contains it — which is
   what `background-clip: text` needs, because a glyph outside the element's own
   box has no background to show and comes out transparent.

   Containing it is not the same as showing all of it. Two numbers do the rest:

   - the text is pulled up by 0.26em, which is the blank ascent above the cap
     line. That gap was the whole of the too-large space between "All rights
     reserved" and the wordmark — 122px of nothing at desktop size.
   - the band ends 0.755em down, which is 0.025em past the baseline. The
     letters keep their feet and the g loses its tail to the foot of the page.
     A wordmark that runs off the edge reads as deliberate; one that sits
     complete in the middle of a band reads as a logo someone pasted in. */
.footmarks {
  position: relative; z-index: 2;
  margin-top: clamp(14px, 1.6vw, 30px);
  height: calc(var(--fm) * 0.755);
  overflow: clip;
}
.footmark {
  position: absolute; left: 0; right: 0; top: calc(var(--fm) * -0.26);
  display: block; text-align: center;
  font-size: var(--fm);
  line-height: 1.3; letter-spacing: -0.03em;
  -webkit-background-clip: text; background-clip: text; color: transparent;
  background-size: cover; background-position: center 42%;
}
/* The letters are the photograph tinted with `orb[0]`, the palette's lightest
   colour — so the wordmark turns with the hour alongside Langu itself, and it
   reads on every palette. Untinted it does not: measured against each base,
   the bare photograph inside the glyphs comes out at 1.30:1 on Afternoon,
   whose crop through that band is as dark as the ground under it. The tint
   takes the worst case to 3.4:1 and costs nothing on the others.

   The four cross-fade on the hero's own 26s clock, so both ends of version A
   turn together; with the app's ground there is one photograph and it stays. */
.footmark.m0 { background-image: linear-gradient(var(--mark-tint), var(--mark-tint)), url(assets/night-wide.jpg); }
.footmark.m1 { background-image: linear-gradient(var(--mark-tint), var(--mark-tint)), url(assets/dusk-wide.jpg); }
.footmark.m2 { background-image: linear-gradient(var(--mark-tint), var(--mark-tint)), url(assets/rose-wide.jpg); }
.footmark.m3 { background-image: linear-gradient(var(--mark-tint), var(--mark-tint)), url(assets/afternoon-wide.jpg); }
.bg-app .footmark.m0 { background-image: linear-gradient(var(--mark-tint), var(--mark-tint)), var(--photo-wide); }
@media (prefers-reduced-motion: no-preference) {
  .footmark.m0 { animation: sky0 26s linear infinite; }
  .footmark.m1 { animation: sky1 26s linear infinite; }
  .footmark.m2 { animation: sky2 26s linear infinite; }
  .footmark.m3 { animation: sky3 26s linear infinite; }
}
@media (prefers-reduced-motion: reduce) {
  .footmark.m1, .footmark.m2, .footmark.m3 { opacity: 0; }
}
.bg-app .footmark.m1,
.bg-app .footmark.m2,
.bg-app .footmark.m3 { display: none; }
.bg-app .footmark.m0 { animation: none; }

/* The footer's own three-pass grain is gone with the slab that needed it.
   It existed because a flat near-black band is the smoothest thing on a page
   made of photographs, so it wanted several times the film's strength. The
   band is a photograph now, and the root film already lands on it at exactly
   the strength everything else gets.

   Leaving it in was not merely redundant, it was broken: `.foot` is
   positioned with a z-index, so it is a stacking context and therefore a
   blending isolation group, and an `overlay` pass inside one composites
   against transparent black — which is to say, as the grey tile itself. With
   the band opaque that never showed; the moment it became a gradient starting
   at zero alpha, the footer painted as a flat grey rectangle with a hard top
   edge, which is the very line the band was rewritten to remove. The same
   trap the film's own note at the top of this file records. */

/* ---------- the legal pages ---------- */

.doc {
  position: relative; z-index: 10;
  padding: 0
    calc(clamp(22px, 5vw, 72px) + env(safe-area-inset-right, 0px))
    calc(clamp(90px, 12vh, 200px) + var(--bot-strip))
    calc(clamp(22px, 5vw, 72px) + env(safe-area-inset-left, 0px));
}
.doc .back {
  width: 44px; height: 44px; margin-left: -12px;
  display: inline-flex; align-items: center; justify-content: center;
  color: rgba(253, 252, 250, 0.8);
  transition: color 0.3s ease, transform 0.4s cubic-bezier(0.2, 0.8, 0.2, 1);
}
.doc .back:hover, .doc .back:focus-visible { color: var(--ink); transform: translate3d(-3px, 0, 0); }

.doc .head { max-width: 760px; margin: clamp(70px, 12vh, 150px) auto 0; }
.doc h1 {
  margin: 0; padding: 0; color: var(--ink);
  font-size: clamp(46px, 8vw, 104px); line-height: 0.96; letter-spacing: -0.035em;
}
.doc h1 em { font-style: italic; }
.doc .stand {
  margin: clamp(18px, 2.4vh, 30px) 0 0;
  font-size: clamp(16px, 1.5vw, 19px); line-height: 1.58;
  color: rgba(253, 252, 250, 0.78);
}
.doc .when {
  margin-top: clamp(16px, 2vh, 26px);
  font-size: 11px; font-weight: 600; letter-spacing: 0.22em;
  text-transform: uppercase; color: rgba(253, 252, 250, 0.62);
}
.doc .body {
  max-width: 760px; margin: clamp(56px, 9vh, 104px) auto 0;
  display: flex; flex-direction: column; gap: clamp(38px, 5vh, 58px);
}
.doc h2 {
  margin: 0; padding: 0; color: var(--ink);
  font-size: clamp(26px, 3.2vw, 38px); line-height: 1.1; letter-spacing: -0.02em;
}
.doc p { margin: 16px 0 0; font-size: clamp(15.5px, 1.35vw, 17px); line-height: 1.7; color: rgba(253, 252, 250, 0.78); }
.doc ul { margin: 16px 0 0; padding-left: 20px; }
.doc li { margin: 11px 0 0; font-size: clamp(15.5px, 1.35vw, 17px); line-height: 1.7; color: rgba(253, 252, 250, 0.78); }
.doc .body a { color: var(--ink); border-bottom: 1px solid rgba(253, 252, 250, 0.34); }
.doc .body a:hover, .doc .body a:focus-visible { border-color: var(--ink); }

/* the reading ground is darker than the hero's, because a long column of type
   needs the photograph behind it held further back than a single headline does */
.doc-scrim {
  position: fixed; inset: 0; z-index: 1; pointer-events: none;
  background: linear-gradient(180deg, rgba(3, 6, 11, 0.58) 0%,
    rgba(3, 6, 11, 0.7) 11%, rgba(3, 6, 11, 0.75) 100%);
}

/* ---------- the artwork switch ----------
   A development affordance for comparing the two background sets, drawn as
   two real buttons rather than hung off something decorative. Above the grain
   (90) and the end guards (110) so nothing can swallow the press. Delete this
   block, its script and the `art-places` rules together when one set wins. */
.artswitch {
  position: fixed; z-index: 200;
  left: max(16px, env(safe-area-inset-left, 0px));
  bottom: calc(16px + var(--bot-strip));
  display: flex; gap: 6px;
  font-size: 12px; letter-spacing: 0.02em;
}
.artswitch button {
  font: inherit; cursor: pointer; text-transform: capitalize;
  padding: 8px 14px; min-height: 34px;
  border-radius: 999px;
  color: rgba(253, 252, 250, 0.82);
  background: rgba(10, 8, 16, 0.62);
  border: 1px solid rgba(255, 255, 255, 0.18);
  backdrop-filter: blur(8px);
  transition: color 0.2s ease, border-color 0.2s ease;
}
.artswitch button:hover,
.artswitch button:focus-visible { color: var(--ink); border-color: rgba(255, 255, 255, 0.4); }
.artswitch button[aria-pressed='true'] { color: var(--ink); border-color: var(--accent); }
