/* Triggers — www.triggersalarm.com
 *
 * Palette, typography and density are transcribed from the iOS app:
 * ios/Triggers/Core/DesignSystem/{Palette,AccentPalette,Typography,
 * Density}.swift. Do not "improve" a value here without changing it in
 * the app first — this page is a claim about what the product looks like.
 *
 * The device chrome is the one exception: the app draws no status bar,
 * island or home indicator of its own, so the hardware here is this
 * page's framing device rather than a copy of anything in the app. Its
 * geometry is the iPhone 17 Pro's published figures. The footer says so.
 */

@font-face {
  font-family: 'Lora';
  src: url('fonts/Lora.ttf') format('truetype');
  font-weight: 400 700;
  font-display: swap;
}

@font-face {
  font-family: 'DMSans';
  src: url('fonts/DMSans.ttf') format('truetype');
  font-weight: 400 700;
  font-display: swap;
}

:root {
  /* Palette.dark */
  --night: #16140F;
  --night-surface: #1F1C16;
  --night-surface2: #28241D; /* recorded, unused: #device mixes bg/surface, not surface2 */
  --night-ink: #F0EBE0;

  /* Palette.light */
  --morning: #F6F4EF;
  --surface: #FBFAF6;
  --surface2: #FFFFFF; /* recorded, unused: #device mixes bg/surface, not surface2 */
  --day-ink: #29261B;

  /* AccentPalette.violet / .violetDark */
  --accent: #7A5AE0;
  --accent-dark: #9079EC;
  --accent-soft: #B8A6F2;
  --accent-tint: rgba(122, 90, 224, 0.12);
  /* AccentPalette.violetDark.tint — 20% of #9079EC. */
  --accent-tint-dark: rgba(144, 121, 236, 0.20);

  /* SolarEventBadge — the app's own dawn colour */
  --amber: #C97A1F;
  --amber-tint: rgba(240, 168, 35, 0.14);

  /* Sky between night and morning, derived from the amber token */
  --dawn: color-mix(in oklab, var(--amber), var(--night) 55%);

  /* RingingDot is the ONE colour on this page that is not an app palette
     token. ArmedAlarmActivity.swift:292 fills it with SwiftUI's
     `Color.red`, which is the system red, not Palette.danger. Copying
     Palette.danger here would be the tidier choice and the wrong one. */
  --ios-red: #FF3B30;
  --ios-red-dark: #FF453A;

  /* Density.comfortable. #device (below) redeclares every one of these
     in point units, scoped to its own subtree, which shadows these
     values for every consumer -- editing them here has no visible
     effect. They are the record of the app's numbers, not the live
     values; the live values are #device's. */
  --row-h: 72px;      /* rowMinH 60 + 12 */
  --row-pad-y: 14px;  /* rowPadY 12 + 2 */
  --row-pad-x: 16px;
  --row-gap: 14px;    /* AlarmRow HStack spacing */
  --badge: 36px;
  --badge-r: 9px;
  --sep-inset: 66px;  /* row-pad-x 16 + badge 36 + row-gap 14 */

  --serif: 'Lora', Georgia, 'Times New Roman', serif;
  --ui: 'DMSans', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;

  --barh: 46px;

  /* Scroll progress. Fallbacks ARE the finished state: with no script,
     the page renders as full morning with every alarm row present. */
  --p: 1;
  --a0: 1; --a1: 1; --a2: 1; --a3: 1;
  --a4: 1; --a5: 1; --a6: 1; --a7: 1;

  /* Pointer, -1..1 either axis. Zero means "no pointer", which is also
     the touch and the no-script state. */
  --mx: 0;
  --my: 0;
}

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

body {
  margin: 0;
  background: var(--morning);
  color: var(--day-ink);
  font-family: var(--ui);
  font-size: 17px;
  line-height: 1.55;
  -webkit-font-smoothing: antialiased;
}

#track {
  position: relative;
  max-width: 1120px;
  margin: 0 auto;
  padding: 0 24px;
  display: grid;
  grid-template-columns: 1fr minmax(300px, 420px);
  gap: 64px;
  align-items: start;
}

#column { grid-column: 1; grid-row: 1; }

#stage {
  grid-column: 2;
  grid-row: 1;
  position: sticky;
  top: 9vh;
  height: 82vh;
  perspective: 1400px;

  /* One scale token for the whole device. Declared here rather than on
     #device because .bloom is a sibling and measures itself in the same
     points. 892 is the device's outer height: 874pt of screen plus 9pt
     of bezel-and-band on each edge. */
  --pt: min(1px, calc(82vh / 892));
}

/* --- Sticky brand bar ------------------------------------------------- */

#topbar {
  position: fixed;
  top: 0;
  left: 0;
  right: 0;
  z-index: 20;
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 16px;
  min-height: var(--barh);
  padding: 8px max(24px, calc((100vw - 1120px) / 2 + 24px));

  /* A scrim of the ground itself, so bar text sits on the same colour the
     page's contrast measurement already covers. The bar cannot fail
     contrast independently of the page it floats over. */
  background: color-mix(in oklab, var(--ground), transparent 20%);
  -webkit-backdrop-filter: blur(18px) saturate(1.25);
  backdrop-filter: blur(18px) saturate(1.25);

  /* Hidden over the hero, which already carries the same lockup. */
  --show: clamp(0, calc((var(--a0) - 0.62) * 7), 1);
  opacity: var(--show);
  transform: translateY(calc((var(--show) - 1) * 100%));
}

.brand { display: inline-flex; align-items: center; gap: 10px; }
.brand img { display: block; border-radius: 22%; }
.brandname { font-family: var(--serif); font-size: 18px; color: var(--ink); }

.appstore {
  display: inline-flex;
  align-items: center;
  gap: 8px;
  padding: 11px 20px;
  border-radius: 999px;
  background: var(--accent);
  color: #FFFFFF;
  font-family: var(--ui);
  font-size: 15px;
  font-weight: 700;
  letter-spacing: 0.01em;
  line-height: 1;
  white-space: nowrap;

  /* White on #7A5AE0 measures 4.9:1, which does not move when the ground
     does. The button is the one element on the page whose contrast is
     independent of scroll position. */
}

#topbar .appstore { padding: 9px 16px; font-size: 14px; }

/* The app is unshipped. This is a placeholder, not a control: no href, no
   tab stop, no pointer affordance. See README's launch-day edit. */
.appstore.is-pending { cursor: default; opacity: 0.92; }

.ctarow { margin: 0 0 22px; max-width: none; }
footer .ctarow { margin: 0 0 30px; }

/* --- Acts ------------------------------------------------------------- */

.act {
  min-height: 92vh;
  display: flex;
  flex-direction: column;
  justify-content: center;
  max-width: 34em;
}

/* Act 3 is the page's only hold. One pin reads as an emphasis; four read
   as being detained, which is why the original design refused all of
   them. Its height is deliberately larger than every other act, which is
   safe only because the sky timeline below is keyed to act progress
   rather than to global --p. */
.act.pinned {
  display: block;
  min-height: 260vh;
  max-width: none;
}

.act.pinned .pin {
  position: sticky;
  top: 0;
  min-height: 100vh;
  display: flex;
  flex-direction: column;
  justify-content: center;
  max-width: 34em;
}

.wordmark {
  display: flex;
  align-items: center;
  gap: 14px;
  font-family: var(--serif);
  font-size: 26px;
  letter-spacing: 0.01em;
  margin: 0 0 32px;
}

.markwrap { position: relative; display: inline-flex; }
.mark { display: block; border-radius: 22%; position: relative; z-index: 1; }

/* The mark itself is the app's real raster, never redrawn. The burst is a
   separate radial drawn behind it, so the product's own artwork cannot
   drift from the app by being traced. */
.rays {
  position: absolute;
  left: 50%;
  top: 50%;
  width: 220px;
  height: 220px;
  transform: translate(-50%, -50%);
  border-radius: 50%;
  /* REPEATING conic, not conic: a plain conic-gradient paints its stops
     once and leaves the rest of the turn transparent, which drew two
     lonely spikes in one quadrant instead of a burst. */
  background:
    radial-gradient(circle,
      color-mix(in oklab, var(--accent), transparent 58%) 0%,
      color-mix(in oklab, var(--accent), transparent 86%) 36%,
      transparent 64%),
    repeating-conic-gradient(from -7deg,
      color-mix(in oklab, var(--amber), transparent 62%) 0deg 2.4deg,
      transparent 2.4deg 15deg);
  -webkit-mask-image: radial-gradient(circle, #000 12%, transparent 62%);
  mask-image: radial-gradient(circle, #000 12%, transparent 62%);
  pointer-events: none;
}

h1 {
  font-family: var(--serif);
  font-weight: 400;
  font-size: clamp(34px, 5.4vw, 64px);
  line-height: 1.12;
  letter-spacing: -0.015em;
  margin: 0 0 28px;
}

h2 {
  font-family: var(--serif);
  font-weight: 400;
  font-size: clamp(28px, 3.6vw, 42px);
  line-height: 1.15;
  margin: 0 0 18px;
}

.eyebrow {
  font-family: var(--ui);
  font-size: 11px;
  font-weight: 700;
  letter-spacing: 0.06em;
  text-transform: uppercase;
  color: var(--accent);
  margin: 0 0 10px;
}

p { margin: 0 0 16px; max-width: 32em; }

.cta {
  font-size: 15px;
  letter-spacing: 0.01em;
  opacity: 0.72;
  margin: 0;
}

footer.act { min-height: 60vh; justify-content: flex-end; padding-bottom: 12vh; }
.req, .disclosure { font-size: 13px; opacity: 0.6; max-width: 30em; }
.links { font-size: 14px; }
.links a { color: inherit; text-underline-offset: 3px; }
.links a:hover { color: var(--accent); }

#sky { position: fixed; inset: 0; z-index: -1; }

/* --- Device: iPhone 17 Pro chrome + reconstructed app UI ---------------
 * Hardware geometry (published figures): 402x874pt logical display,
 * display corner radius 62pt, Dynamic Island 125x37pt sitting 11pt below
 * the top of the display, status bar 54pt, home indicator 140x5pt.
 * A 6pt bezel and a 3pt frame band put the outer body at 420x892pt with
 * a 71pt radius.
 *
 * App UI geometry is the measured AlarmRow layout: 36x36 radius-9 badge,
 * 14px gap, 14/16 padding, 72px minimum height, 0.5px separator inset 66.
 */

#device {
  /* Density.comfortable, scaled. Same numbers as :root, in points. */
  --row-h: calc(72 * var(--pt));
  --row-pad-y: calc(14 * var(--pt));
  --row-pad-x: calc(16 * var(--pt));
  --row-gap: calc(14 * var(--pt));
  --badge: calc(36 * var(--pt));
  --badge-r: calc(9 * var(--pt));
  --sep-inset: calc(66 * var(--pt));

  position: relative;
  z-index: 2; /* above .bloom -- see the note there */
  width: calc(420 * var(--pt));
  height: calc(892 * var(--pt));
  margin: 0 auto;
  border-radius: calc(71 * var(--pt));
  padding: calc(9 * var(--pt));

  /* Brushed aluminium band. Neutral-warm rather than grey so it sits in
     the same light as the rest of the page. */
  background:
    linear-gradient(145deg,
      #E2DED6 0%, #97918A 14%, #D3CEC5 30%,
      #6E6961 52%, #C9C4BB 70%, #8B857D 86%, #DCD8D0 100%);

  transform:
    translate3d(var(--shakex, 0px), var(--shakey, 0px), 0)
    rotateY(calc(var(--mx) * 4deg))
    rotateX(calc(var(--my) * -3deg));
  transform-style: preserve-3d;
}

/* Hardware buttons. Positioned against the outer body, protruding 2.5pt,
   at the 17 Pro's arrangement: Action / Volume Up / Volume Down on the
   left, Side and Camera Control on the right. */
.side {
  position: absolute;
  width: calc(3 * var(--pt));
  border-radius: calc(1.6 * var(--pt));
  background: linear-gradient(90deg, #7E7871, #C6C1B8, #857F78);
}
.side-action { left: calc(-2.5 * var(--pt)); top: calc(158 * var(--pt)); height: calc(28 * var(--pt)); }
.side-volup  { left: calc(-2.5 * var(--pt)); top: calc(212 * var(--pt)); height: calc(35 * var(--pt)); }
.side-voldn  { left: calc(-2.5 * var(--pt)); top: calc(262 * var(--pt)); height: calc(35 * var(--pt)); }
.side-power  { right: calc(-2.5 * var(--pt)); top: calc(224 * var(--pt)); height: calc(62 * var(--pt)); }
.side-cam    { right: calc(-2.5 * var(--pt)); top: calc(322 * var(--pt)); height: calc(25 * var(--pt)); }

.screen {
  position: relative;
  height: 100%;
  border-radius: calc(62 * var(--pt));
  overflow: hidden;
  background: var(--d-bg);
  color: var(--d-ink);
  display: flex;
  flex-direction: column;

  /* The bezel reads as glass rather than paint only if there is a hard
     edge between it and the band. */
  box-shadow:
    0 0 0 calc(1 * var(--pt)) rgba(12, 10, 8, 0.55),
    inset 0 0 calc(14 * var(--pt)) rgba(12, 10, 8, 0.18);
}

/* --- Dynamic Island ---------------------------------------------------
 * Not a decorative cutout. The app ships a real Live Activity with a real
 * island (ArmedAlarmActivity.swift:23-45): compactLeading is the provider
 * badge, compactTrailing is the armed-until time, which becomes the
 * RingingDot the moment a match lands. The island widens when it has
 * something to carry, exactly as iOS does.
 */
.island {
  position: absolute;
  top: calc(11 * var(--pt));
  left: 50%;
  z-index: 6;
  height: calc(37 * var(--pt));
  width: calc((125 + var(--lp, 0) * 54) * var(--pt));
  transform: translateX(-50%);
  border-radius: calc(18.5 * var(--pt));
  background: #05040A;
  display: flex;
  align-items: center;
  justify-content: space-between;
  padding: 0 calc(8 * var(--pt));
  overflow: hidden;
}

/* ProviderBadge, size 20 in compactLeading. The generic provider draws a
   middle dot on the accent — no third party's mark appears on this page. */
.di-lead {
  flex: 0 0 auto;
  width: calc(20 * var(--pt));
  height: calc(20 * var(--pt));
  border-radius: 50%;
  background: var(--d-accent);
  color: #FFFFFF;
  font-size: calc(10 * var(--pt));
  font-weight: 600;
  line-height: calc(18 * var(--pt));
  text-align: center;
  opacity: var(--lp, 0);
}

.di-trail { position: relative; display: grid; place-items: center; }
.di-trail > * { grid-area: 1 / 1; }

.di-time {
  font-size: calc(13 * var(--pt));
  font-weight: 500;
  font-variant-numeric: tabular-nums;
  color: var(--d-accent);
  white-space: nowrap;
  opacity: calc(var(--lp, 0) * var(--sw-out));
}

/* RingingDot, 12 in compactTrailing. */
.di-dot {
  width: calc(12 * var(--pt));
  height: calc(12 * var(--pt));
  border-radius: 50%;
  background: var(--d-red);
  opacity: calc(var(--lp, 0) * var(--sw-in));
}

.statusbar {
  position: relative;
  z-index: 5;
  height: calc(54 * var(--pt));
  display: flex;
  align-items: center;
  justify-content: space-between;
  padding: 0 calc(28 * var(--pt)) calc(6 * var(--pt));
  font-size: calc(15 * var(--pt));
  font-weight: 600;
  color: var(--d-ink);
}

.sb-left { position: relative; display: grid; }
.sb-left .clock { grid-area: 1 / 1; font-variant-numeric: tabular-nums; }
.clock-night { opacity: calc(1 - var(--dawnp)); }
.clock-day   { opacity: var(--dawnp); }

.sb-right { display: inline-flex; align-items: center; gap: calc(5 * var(--pt)); }
.sb-right svg { display: block; height: calc(11 * var(--pt)); width: auto; }

.homebar {
  position: absolute;
  z-index: 6;
  left: 50%;
  bottom: calc(8 * var(--pt));
  transform: translateX(-50%);
  width: calc(140 * var(--pt));
  height: calc(5 * var(--pt));
  border-radius: calc(2.5 * var(--pt));
  background: var(--d-ink);
  opacity: 0.42;
}

.list { padding: calc(4 * var(--pt)) calc(12 * var(--pt)); }

.listtitle {
  font-family: var(--serif);
  font-size: calc(26 * var(--pt));
  margin: calc(4 * var(--pt)) 0 calc(12 * var(--pt)) var(--row-pad-x);
}

.row {
  display: flex;
  align-items: center;
  gap: var(--row-gap);
  padding: 0 var(--row-pad-x);
  background: var(--d-surface);
  position: relative;
}

/* The card's shape belongs to the container, not to whichever row is
   last in the DOM. Rows collapse to zero height during accretion, so
   :last-of-type is frequently not the visual bottom. */
.rows {
  border-radius: calc(12 * var(--pt));
  overflow: hidden;
}

/* Attached to each row's top rather than the row above's bottom,
   because the DOM's last-of-type can be collapsed while an earlier row
   is the visual last -- keyed the old way, the bottom row drew a
   trailing hairline with nothing beneath it. Rows accrete strictly in
   order, so the visually-first row is always the Time row and
   :first-of-type is stable. For an ordered list this renders the
   identical hairline on the identical boundary as the app's
   bottom-except-last construction. */
.row:not(:first-of-type)::after {
  content: "";
  position: absolute;
  left: var(--sep-inset);
  right: 0;
  top: 0;
  height: calc(0.5 * var(--pt));
  background: var(--d-sep);
}

.badge {
  flex: 0 0 auto;
  width: var(--badge);
  height: var(--badge);
  border-radius: var(--badge-r);
  display: grid;
  place-items: center;
}

.badge.violet { background: var(--d-accent-tint); color: var(--d-accent); }
.badge.amber  { background: var(--amber-tint);  color: var(--amber); }

/* The glyph is 55% of the badge, as TriggerIcon/SolarEventBadge/
   CalendarEventBadge all compute it. Expressed as a relationship rather
   than a literal so it stays correct if the badge size changes. The
   inline width/height on the SVGs are the unscaled fallback. */
.badge svg {
  width: calc(var(--badge) * 0.55);
  height: calc(var(--badge) * 0.55);
}

.text { display: flex; flex-direction: column; gap: calc(2 * var(--pt)); flex: 1 1 auto; min-width: 0; }
.line { display: flex; align-items: baseline; gap: calc(8 * var(--pt)); }

.big   { font-family: var(--serif); font-size: calc(30 * var(--pt)); line-height: 1; letter-spacing: calc(-1 * var(--pt)); }
.mer   { font-size: calc(14 * var(--pt)); font-weight: 600; }
.label { font-family: var(--serif); font-size: calc(22 * var(--pt)); line-height: 1.1; letter-spacing: calc(-0.4 * var(--pt)); }
.mid    { font-family: var(--serif); font-size: calc(26 * var(--pt)); line-height: 1; letter-spacing: calc(-0.6 * var(--pt)); }
.offset { font-size: calc(15 * var(--pt)); font-weight: 500; color: var(--d-ink2); }
.sub   { font-size: calc(13 * var(--pt)); color: var(--d-ink2); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }

.switch {
  flex: 0 0 auto;
  width: calc(44 * var(--pt));
  height: calc(26 * var(--pt));
  border-radius: calc(13 * var(--pt));
  background: var(--d-sep);
  position: relative;
}

.switch.on { background: var(--d-accent); }

.switch::after {
  content: "";
  position: absolute;
  top: calc(3 * var(--pt));
  left: calc(3 * var(--pt));
  width: calc(20 * var(--pt));
  height: calc(20 * var(--pt));
  border-radius: 50%;
  background: #FFFFFF;
  box-shadow: 0 1px 3px rgba(0, 0, 0, 0.28);
}

.switch.on::after { left: calc(21 * var(--pt)); }

/* --- Lock Screen presentation ----------------------------------------
 * This is LockScreenView from ArmedAlarmActivity.swift:51-146 — the app's
 * real Live Activity, in its two states. Geometry is that file's:
 * HStack spacing 14, padding 16/12, a 44pt provider badge, a VStack at
 * spacing 4, title 16 semibold, subtitle 13 secondary, and a trailing
 * "until" over the time at 18 medium monospaced, which is replaced by the
 * 14pt RingingDot the moment the state becomes .firing.
 *
 * It starts below the status bar because a real Lock Screen does.
 */
.lock {
  position: absolute;
  inset: calc(54 * var(--pt)) 0 0 0;
  background: var(--d-bg);
  padding: calc(10 * var(--pt)) calc(16 * var(--pt)) calc(16 * var(--pt));
  display: flex;
  flex-direction: column;
  opacity: 0;
  pointer-events: none;
}

.lockclock {
  font-family: var(--serif);
  font-size: calc(62 * var(--pt));
  line-height: 1;
  text-align: center;
  margin: calc(24 * var(--pt)) 0 auto;
}

.lscard {
  display: flex;
  align-items: center;
  gap: calc(14 * var(--pt));
  padding: calc(12 * var(--pt)) calc(16 * var(--pt));
  border-radius: calc(22 * var(--pt));

  /* .activityBackgroundTint(armedAccent.opacity(0.10)) over the system
     material — ArmedAlarmActivity.swift:21. Two layers, in that order. */
  background:
    linear-gradient(color-mix(in oklab, var(--d-accent), transparent 90%),
                    color-mix(in oklab, var(--d-accent), transparent 90%)),
    color-mix(in oklab, var(--d-surface), transparent 6%);
  box-shadow: 0 calc(6 * var(--pt)) calc(22 * var(--pt)) rgba(20, 18, 14, 0.16);
}

/* ProviderBadge(size: 44) — the generic letter is a middle dot. */
.pbadge {
  flex: 0 0 auto;
  width: calc(44 * var(--pt));
  height: calc(44 * var(--pt));
  border-radius: 50%;
  background: var(--d-accent);
  color: #FFFFFF;
  font-size: calc(22 * var(--pt));
  font-weight: 600;
  line-height: calc(38 * var(--pt));
  text-align: center;
}

/* Both states occupy the same grid cell so the card does not resize as it
   flips — the app's card does not resize either, its content swaps. */
.lstext { position: relative; display: grid; flex: 1 1 auto; min-width: 0; }
.lsstate {
  grid-area: 1 / 1;
  display: flex;
  flex-direction: column;
  gap: calc(4 * var(--pt));
  min-width: 0;
}

.lsstate[data-ls="watching"] { opacity: var(--sw-out); }
.lsstate[data-ls="firing"]   { opacity: var(--sw-in); }

.lstitle {
  display: flex;
  align-items: baseline;
  gap: calc(6 * var(--pt));
  font-size: calc(16 * var(--pt));
  font-weight: 600;
  min-width: 0;
}

.lseyebrow {
  flex: 0 0 auto;
  font-size: calc(11 * var(--pt));
  font-weight: 700;
  letter-spacing: 0.06em;
  text-transform: uppercase;
  color: var(--d-accent);
}

.lsfrom { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }

.lssub {
  font-size: calc(13 * var(--pt));
  color: var(--d-ink2);
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}

.lstrail { position: relative; margin-left: auto; display: grid; place-items: center end; }
.lstrail > * { grid-area: 1 / 1; }

/* Collapses to zero width once it has faded, so the firing state's sender
   gets the room. Sharing a grid cell would otherwise leave the "until"
   block reserving its width at zero opacity and squeezing the address into
   an ellipsis -- and the app truncates addresses in the MIDDLE
   (ArmedAlarmActivity.swift:83), which CSS cannot do, so the honest fix is
   to leave enough room rather than to truncate differently. The app
   replaces this view too; it does not overlay it. */
.lsuntil {
  display: flex;
  flex-direction: column;
  align-items: flex-end;
  gap: calc(2 * var(--pt));
  opacity: var(--sw-out);
  max-width: calc(var(--sw-out) * 120 * var(--pt));
  overflow: hidden;
}
.lsuntillabel { font-size: calc(11 * var(--pt)); color: var(--d-ink3); }
.lsuntiltime {
  font-size: calc(18 * var(--pt));
  font-weight: 500;
  font-variant-numeric: tabular-nums;
  white-space: nowrap;
}

/* RingingDot, 14pt. */
.lsdot {
  width: calc(14 * var(--pt));
  height: calc(14 * var(--pt));
  border-radius: 50%;
  background: var(--d-red);
  opacity: var(--sw-in);
  box-shadow: 0 0 0 calc(6 * var(--pt) * var(--sw-in))
              color-mix(in oklab, var(--d-red), transparent 78%);
}

/* --- The peak's bloom -------------------------------------------------
 * Confined to the device and a halo around it. Staying under 25% of the
 * viewport is what keeps this outside WCAG 2.3.1's general-flash
 * threshold by construction rather than by argument — tools/shoot.py
 * bloom measures it. Amber, the app's own dawn colour, not white: a warm
 * swell rather than a strobe.
 */
.bloom {
  position: absolute;
  /* BEHIND the device, explicitly. Both this and #device are positioned
     inside #stage's 3D rendering context (it carries `perspective`), and
     leaving #device on `z-index: auto` let the bloom paint OVER the phone
     -- washing out the metal band and the screen at exactly the moment the
     peak is meant to draw the eye to them. The halo belongs around the
     device, not on top of it. */
  z-index: 1;
  left: 50%;
  top: 50%;
  width: calc(560 * var(--pt));
  height: calc(560 * var(--pt));
  transform: translate(-50%, -50%) scale(calc(0.72 + var(--bloomp, 0) * 0.34));
  border-radius: 50%;
  pointer-events: none;
  background: radial-gradient(circle,
    color-mix(in oklab, var(--amber), transparent 42%) 0%,
    color-mix(in oklab, var(--amber), transparent 74%) 44%,
    transparent 70%);
  opacity: calc(var(--bloomp, 0) * 0.6);
}

/* --- Ground travel and act choreography ---
 * Contrast governs the timing here, so the ramps are not free parameters.
 * All figures below were measured by rendering the real stylesheet in
 * headless Chrome over the DevTools Protocol and sampling COMPOSITED
 * background pixels (tools/shoot.py sweep), not estimated, and not read
 * off the --ground token.
 *
 * Night ink #F0EBE0 (luminance 0.833) holds 4.5:1 only while the ground
 * stays at or below luminance 0.146. Day ink #29261B (luminance 0.019)
 * does not reach 4.5:1 until the ground passes 0.262. Those two windows
 * do not overlap, so a continuous ground travelling from night to
 * morning must cross a gap where neither ink clears 4.5:1 — no choice
 * of switch point avoids it, and no third ink fixes it either (a ground
 * at luminance 0.20 would need ink darker than luminance 0.006, darker
 * than the app's own text colour). The band is unavoidable; only its
 * width and depth are ours.
 *
 * WHAT CHANGED, AND WHY IT MATTERS TO WHOEVER EDITS THIS NEXT:
 * every ramp below is keyed to per-act progress (--a2, --a3, --a4) and
 * NOT to global --p. The previous build keyed the ink step to
 * --p 0.608, a number that was only correct for exactly seven 92vh acts
 * and a 60vh footer; changing any act's height silently invalidated the
 * whole contrast argument while the gate stayed green. Act 3 is now
 * 260vh, which would have broken it. Keyed to --a4, act heights are free
 * parameters again and the crossover cannot drift when the page is
 * re-cut.
 */

:root {
  /* Two-stage night lift: act 2 takes the sky off pure night, act 3
     finishes the journey to dawn. Each term is clamped independently so
     neither can drive the sum backwards. */
  --lift-a: clamp(0, calc((var(--a2) - 0.25) * 1.8), 1);
  --lift-b: clamp(0, calc((var(--a3) - 0.20) * 1.6), 1);
  --nightlift: calc(var(--lift-a) * 0.45 + var(--lift-b) * 0.55);

  --ground-a: color-mix(in oklab, var(--night), var(--dawn)
               calc(var(--nightlift) * 100%));

  /* Steep, so the illegible band between the two inks stays narrow. Keyed
     to act 4 — Solar — which is also the act that turns the phone's own
     theme from dark to light, so sky and device cross together by
     construction rather than by coincidence. */
  --daybreak: clamp(0, calc((var(--a4) - 0.30) * 7), 1);
  --ground: color-mix(in oklab, var(--ground-a), var(--morning)
             calc(var(--daybreak) * 100%));

  /* Ink flips at the measured crossover, and flips as a STEP rather than
     a blend. An interpolated ink passes through mid-grey exactly where
     the ground is mid-tone, which measures 1.33:1 -- effectively
     invisible. Stepping holds the worst case at the best this pair of
     inks allows. The ramp is about a pixel of scrolling, so it reads as
     instantaneous. The constant is measured; see the block comment above
     before touching it. */
  --switch: clamp(0, calc((var(--a4) - 0.3461) * 8000), 1);
  --ink: color-mix(in oklab, var(--night-ink), var(--day-ink)
          calc(var(--switch) * 100%));
}

#sky { background: var(--ground); }

#sun {
  display: block;
  position: absolute;
  /* Off-centre, toward the device column, for two reasons that happen to
     agree. Composition: the sun rising BEHIND the phone is the picture
     this page is trying to make. Contrast: centred, this gradient is
     ~893px across at a 1440 viewport and washes the whole copy column,
     which measured a 0.10 penalty on the crossover floor. Moved right,
     its bright core sits over the device instead of over the prose.
     Re-measure with `tools/shoot.py cross` if you move it back. */
  left: 68%;
  bottom: 0;
  width: 50vmax;
  height: 50vmax;
  /* Vertical travel stays on global --p: a monotonic rise across the
     whole page is the correct reading of a sunrise, and it is what keeps
     a var(--p) consumer in this stylesheet at all. Its OPACITY is keyed
     to act progress so the sun cannot drift away from the sunrise it
     belongs to when an act is re-cut. */
  transform: translate(-50%, calc(46vmax - var(--p) * 48vmax));
  border-radius: 50%;
  background: radial-gradient(circle at 50% 50%,
              color-mix(in oklab, var(--amber), transparent 30%) 0%,
              color-mix(in oklab, var(--amber), transparent 78%) 42%,
              transparent 68%);
  /* Peak is deliberately low. At full strength this gradient is ~892px
     across at a typical desktop viewport and washes the whole copy
     column. Capping the sun keeps the worst case AT the ground-only
     floor produced by the ink step above, instead of letting the sun
     push it lower -- the sun stops being the limiting factor. Measure
     the composited background, not --ground, if you change it. */
  opacity: calc(0.30 * min(
    clamp(0, calc((var(--a3) - 0.45) * 3), 1),
    clamp(0, calc((0.86 - var(--a5)) * 4), 1)
  ));
}

body { color: var(--ink); }

/* Page text only. Everything inside #device follows the device's own
   theme (--d-ink) — recolouring .big/.label/.lockclock/.sub here would
   make the phone track the sky and erase the dark-to-light crossfade that
   is the page's climax. */
.wordmark, h1, h2, .cta, .req, .disclosure, .brandname { color: var(--ink); }

/* Each act rises into place on its own progress value. The
   0.18 → 0.38 band is the early part of an act's travel through the
   viewport, which is where its copy sits beside the device. */
.act > * {
  --rev: clamp(0, calc((var(--ap) - 0.18) * 5), 1);
  opacity: max(0.12, var(--rev));
  transform: translateY(calc((1 - var(--rev)) * 18px));
}

/* The pinned act reveals on a much earlier slice of its own progress,
   because 260vh of act means 0.18 lands long after the copy is on
   screen. */
.act.pinned > .pin {
  --rev: clamp(0, calc((var(--ap) - 0.06) * 14), 1);
}

/* These carry their own authored dimming, so the reveal multiplies it
   rather than replacing it. They are (0,2,0) against .act > *'s (0,1,0),
   so they win on specificity outright — source order after it is not
   what makes this work. */
.act > .cta { opacity: calc(0.72 * max(0.12, var(--rev))); }
.act > .req,
.act > .disclosure { opacity: calc(0.6 * max(0.12, var(--rev))); }

.act[data-act="0"] { --ap: var(--a0); }
.act[data-act="1"] { --ap: var(--a1); }
.act[data-act="2"] { --ap: var(--a2); }
.act[data-act="3"] { --ap: var(--a3); }
.act[data-act="4"] { --ap: var(--a4); }
.act[data-act="5"] { --ap: var(--a5); }
.act[data-act="6"] { --ap: var(--a6); }
.act[data-act="7"] { --ap: var(--a7); }

/* --- Device choreography: rows accrete, the Lock Screen rises, the alarm
 * fires, the interior crosses to daylight. All driven by the per-act
 * progress properties app.js writes onto :root.
 */

/* 1. Rows accrete one per act. Height and vertical padding both ride --rv:
      under border-box, padding is a floor `height` cannot go below, so a
      collapsed row would otherwise still hold 2 x 14pt and the list would
      never close up. */
.row {
  --rp: 1;
  --rv: clamp(0, calc((var(--rp) - 0.28) * 8), 1);
  opacity: clamp(0, calc((var(--rp) - 0.30) * 6), 1);
  height: calc(var(--row-h) * var(--rv));
  padding-top: calc(var(--row-pad-y) * var(--rv));
  padding-bottom: calc(var(--row-pad-y) * var(--rv));
  overflow: hidden;
}

.row[data-trigger="time"]     { --rp: var(--a2); }
/* Act 3 is 260vh, so its early slice is where the row belongs. */
.row[data-trigger="email"]    { --rp: clamp(0, calc(var(--a3) * 2.6), 1); }
.row[data-trigger="solar"]    { --rp: var(--a4); }
.row[data-trigger="calendar"] { --rp: var(--a5); }

/* 2. The Lock Screen holds, the alarm fires, the card flips.
 *
 *    The pin covers --a3 0.32 -> 0.68 (260vh act, so 100vh of held
 *    scrolling out of 360vh of act travel), and every window below sits
 *    inside it. min() of a rising and a falling ramp gives a trapezoid
 *    without abs(), which is still too new to rely on.
 */
#device, #stage {
  --lp: min(
    clamp(0, calc((var(--a3) - 0.340) * 18), 1),
    clamp(0, calc((0.670 - var(--a3)) * 22), 1)
  );

  /* The match lands. */
  --fire: min(
    clamp(0, calc((var(--a3) - 0.520) * 22), 1),
    clamp(0, calc((0.645 - var(--a3)) * 26), 1)
  );

  /* The two Lock Screen states SWAP, they do not cross-dissolve. Fading
     one text block through another leaves both legible at the midpoint,
     which renders as overlapping garble rather than as a transition --
     "Deploy alerts" printed through "ci@example.com". The old state is
     fully gone before the new one starts, with a short beat between,
     which is also how the real card behaves: its content is replaced,
     not blended. Both the card and the island read these. */
  --sw-out: clamp(0, calc((0.42 - var(--fire, 0)) * 4), 1);
  --sw-in:  clamp(0, calc((var(--fire, 0) - 0.48) * 4), 1);

  /* The bloom is shorter than the state change: it marks the instant,
     then gets out of the way. */
  --bloomp: min(
    clamp(0, calc((var(--a3) - 0.522) * 55), 1),
    clamp(0, calc((0.595 - var(--a3)) * 26), 1)
  );

  /* A decaying shake. sin()/cos() reached baseline before color-mix(),
     which twelve declarations in this file already require, so this adds
     no new browser floor. Phase rides --a3 so the oscillation is a
     function of scroll position and cannot run away on its own. */
  --quake: min(
    clamp(0, calc((var(--a3) - 0.520) * 60), 1),
    clamp(0, calc((0.600 - var(--a3)) * 26), 1)
  );
  --shakex: calc(sin(var(--a3) * 260rad) * var(--quake) * 4.5 * var(--pt));
  --shakey: calc(cos(var(--a3) * 190rad) * var(--quake) * 2.5 * var(--pt));
}

.lock {
  opacity: var(--lp);
  /* Scaled, not a raw pixel value. Everything inside the device is
     expressed in points against --pt; a bare 14px here is ~14pt on a tall
     desktop window but ~56pt on a phone, which slid the card in from well
     outside the screen and clipped it against .screen's overflow. */
  transform: translateY(calc((1 - var(--lp)) * 14 * var(--pt)));
}

/* 3. The app's dark theme becomes its light theme at sunrise.
      --dawnp is 0 before act 4's early slice and 1 after it. */
#device {
  --dawnp: clamp(0, calc((var(--a4) - 0.36) * 3.4), 1);

  /* AlarmListView.swift:50 theme.palette.bg / AlarmRow.swift:23
     theme.palette.surface -- the two largest surfaces, mixed from the
     bg/surface pair, not surface/surface2. */
  --d-bg:      color-mix(in oklab, var(--night), var(--morning)         calc(var(--dawnp) * 100%));
  --d-surface: color-mix(in oklab, var(--night-surface), var(--surface) calc(var(--dawnp) * 100%));
  --d-ink:     color-mix(in oklab, var(--night-ink), var(--day-ink)     calc(var(--dawnp) * 100%));
  /* Palette.swift:51 text2 -- ink @62% light, paper @66% dark. */
  --d-ink2:    color-mix(in oklab, var(--d-ink), transparent calc(34% + var(--dawnp) * 4%));
  /* Palette.swift:52 text3 -- ink @42% light, paper @48% dark. */
  --d-ink3:    color-mix(in oklab, var(--d-ink), transparent calc(52% + var(--dawnp) * 6%));
  --d-sep:     color-mix(in oklab, var(--d-ink), transparent 90%);
  --d-accent:  color-mix(in oklab, var(--accent-dark), var(--accent)    calc(var(--dawnp) * 100%));
  --d-accent-tint: color-mix(in oklab, var(--accent-tint-dark), var(--accent-tint) calc(var(--dawnp) * 100%));
  --d-red:     color-mix(in oklab, var(--ios-red-dark), var(--ios-red)  calc(var(--dawnp) * 100%));

  box-shadow: 0 calc(30 * var(--pt)) calc(70 * var(--pt)) rgba(20, 18, 14, calc(0.10 + var(--dawnp) * 0.16));
}

/* --- Arrival ----------------------------------------------------------
 * The hero assembles once, on load. Scroll cannot drive this: nobody has
 * scrolled yet when they arrive. Every animation ends on the value the
 * element already has declared, and none uses a fill mode, so when the
 * animation finishes the declared value takes over with no jump — which
 * is also why .cta animates transform only, its opacity being a calc().
 */
@keyframes rise-in {
  from { opacity: 0; transform: translateY(22px); }
  to   { opacity: 1; transform: translateY(0); }
}

@keyframes rise-only {
  from { transform: translateY(22px); }
  to   { transform: translateY(0); }
}

/* A vertical mask wipe over a naturally-wrapped heading reveals it one
   line at a time without splitting the tagline across elements — which
   matters, because verify.sh greps for that sentence contiguously and
   splitting it into per-line spans would fail the gate. */
@keyframes headline-wipe {
  from {
    -webkit-mask-position: 0 -130%;
    mask-position: 0 -130%;
    opacity: 0.25;
  }
  to {
    -webkit-mask-position: 0 0;
    mask-position: 0 0;
    opacity: 1;
  }
}

@keyframes device-wake {
  from { opacity: 0; transform: translateY(26px) scale(0.972); }
  to   { opacity: 1; transform: translateY(0) scale(1); }
}

@keyframes island-open {
  from { width: calc(28 * var(--pt)); opacity: 0.35; }
  to   { width: calc(125 * var(--pt)); opacity: 1; }
}

@keyframes rays-burst {
  from { opacity: 0; transform: translate(-50%, -50%) scale(0.35) rotate(-22deg); }
  60%  { opacity: 0.9; }
  to   { opacity: 0.55; transform: translate(-50%, -50%) scale(1) rotate(0deg); }
}

.act[data-act="0"] .wordmark { animation: rise-in 0.85s cubic-bezier(.22,.7,.25,1) both; }

.act[data-act="0"] h1 {
  -webkit-mask-image: linear-gradient(to bottom, #000 0 55%, transparent 78%);
  mask-image: linear-gradient(to bottom, #000 0 55%, transparent 78%);
  -webkit-mask-size: 100% 260%;
  mask-size: 100% 260%;
  -webkit-mask-repeat: no-repeat;
  mask-repeat: no-repeat;
  animation: headline-wipe 1.5s 0.18s cubic-bezier(.3,.7,.2,1) both;
}

.act[data-act="0"] .ctarow { animation: rise-in 0.8s 0.95s cubic-bezier(.22,.7,.25,1) both; }
.act[data-act="0"] #cta    { animation: rise-only 0.8s 1.1s cubic-bezier(.22,.7,.25,1) both; }
.rays  { animation: rays-burst 1.5s 0.25s cubic-bezier(.2,.75,.2,1) both; }
#device { animation: device-wake 1.1s 0.1s cubic-bezier(.22,.7,.25,1) both; }
.island { animation: island-open 0.8s 0.75s cubic-bezier(.3,.8,.2,1) both; }

@media (prefers-reduced-motion: reduce) {
  #sky { background: var(--morning); }
  #sun { display: none; }
  body { color: var(--day-ink); }
  .wordmark, h1, h2, .cta, .req, .disclosure, .brandname { color: var(--day-ink); }
  .act > * { --rev: 1; transform: none; }

  .row {
    opacity: 1;
    min-height: var(--row-h);
    height: var(--row-h);
    padding-top: var(--row-pad-y);
    padding-bottom: var(--row-pad-y);
  }
  #device { --dawnp: 1; transform: none; }
  #device, #stage { --lp: 0; --fire: 0; --bloomp: 0; --quake: 0; }
  .lock { opacity: 0; }
  .bloom { display: none; }

  /* No arrival sequence, no mask, no burst: the finished state, at once. */
  .act[data-act="0"] .wordmark,
  .act[data-act="0"] h1,
  .act[data-act="0"] .ctarow,
  .act[data-act="0"] #cta,
  .rays, #device, .island { animation: none; }
  .act[data-act="0"] h1 { -webkit-mask-image: none; mask-image: none; }
  .island { width: calc(125 * var(--pt)); }
  #topbar { --show: 1; }
}

@media (max-width: 860px) {
  #track {
    grid-template-columns: 1fr;
    gap: 0;
    padding: 0 20px;
  }

  /* Opaque, so copy passing beneath is cleanly hidden rather than
     sliced by the phone's silhouette with sky showing around it.
     30vh/90vh/42vh (band/act min-height/act padding-top) were reached
     by measuring, not guessed: a sweep of band heights 30-38vh, act
     min-heights 82-120vh and paddings 34-50vh against the 13-point
     scroll-fraction acceptance check never reached zero straddling
     elements -- with ~8 act transitions sharing 13 fixed sample
     points, some transition landing on some sample is a near
     inevitability of continuous scroll under a fixed occluding band,
     the same as any sticky header. This combination minimises it while
     keeping every sample's dead-zone check clear.

     THE BAR LIVES INSIDE THIS BAND. #topbar is fixed at the top and is
     shorter than 30vh at every supported height, so the page still has
     exactly ONE occluding element and the measured 42vh clearance below
     still holds. Do not give the bar its own band. */
  #stage {
    grid-column: 1;
    grid-row: 1;
    position: sticky;
    top: 0;
    height: 30vh;
    z-index: 1;
    margin-bottom: -30vh;
    padding-top: var(--barh);
    pointer-events: none;
    background: var(--ground);
    perspective: none;
    --pt: min(1px, calc(22vh / 892));
  }

  /* One column here, so there is no device column for the sun to sit
     behind. Centred is the honest composition, and the copy column is
     the whole width either way. */
  #sun { left: 50%; }

  #column { grid-column: 1; grid-row: 2; }

  /* padding-top clears the band so an act's copy centres below it
     rather than behind it. */
  .act {
    min-height: 90vh;
    max-width: none;
    padding-top: 42vh;
  }

  .act.pinned { min-height: 230vh; padding-top: 0; }
  .act.pinned .pin { min-height: 100vh; padding-top: 42vh; max-width: none; }

  h1 { font-size: clamp(30px, 8vw, 44px); }
  p { max-width: none; }

  .wordmark { font-size: 22px; }
  .mark { width: 38px; height: 38px; }
  .rays { width: 150px; height: 150px; }

  /* The pointer tilt is a mouse affordance. Touch never sets --mx/--my,
     but zeroing them here means a hybrid device cannot leave the phone
     skewed after a mouse was unplugged. */
  #device { transform: translate3d(var(--shakex, 0px), var(--shakey, 0px), 0); }

  /* footer.act's own (tag+class) specificity otherwise beats the plain
     .act rule above and keeps the desktop 60vh/flex-end/12vh footer
     treatment even here, silently shrinking and repositioning the last
     act relative to every other one and throwing off its band
     clearance. Normalise it to match. */
  footer.act { min-height: 90vh; justify-content: center; padding-bottom: 0; }
}

@media (max-width: 420px) {
  body { font-size: 16px; }
  #topbar { padding-inline: 16px; }
  #topbar .appstore { padding: 8px 13px; font-size: 13px; }
  .brandname { font-size: 16px; }
}

@media (max-width: 340px) {
  #stage { height: 26vh; margin-bottom: -26vh; --pt: min(1px, calc(18vh / 892)); }
}
