/* ══════════════════════════════════════════════════════════════════════
   apps.tnaado.ca, the consumer app portfolio. PROJECT REBOOT site 3 of 8.

   ONE STYLESHEET FOR THREE PAGES. Ethan: "i want 3 separate pages, not scroll
   down. coming soon and hold need their own pages." Live is the index,
   /coming-soon and /planned are their own pages, and each carries exactly one
   journey for its own set. Nothing about the page CSS is per page, so it lives
   here rather than inline: three inline copies of this file is precisely the
   duplication that produced almost every defect in this project.

   THE JOURNEY IS PORTED, NOT REINTERPRETED. Every .journey-* rule below
   comes from tnaado-media-next/apps.html, which is the page Ethan asked
   for by name: a scroll scrubbed sequence where each app takes the whole
   frame, its own accent colour becomes the background, and its real
   screenshots sit beside its copy. Sizes, timings, the scrim recipe and
   the two-shape panel contract are that file's, carried across.

   FOUR THINGS ARE DELIBERATELY DIFFERENT:

   1. Eleven panels, not nine. BRAT and Tector are added.
   2. Three journeys, not one, grouped Live then Coming soon then Planned,
      each with its own track, its own background and its own rail. The
      header carries those three as its navigation.
   3. THE STATUSES FROM THE SOURCE FILE ARE NOT CARRIED OVER. That page
      says GEB is "Live" and Citadel is "Live". Neither is true. Status
      here comes from project-reboot/PRODUCTS.md and from the band a panel
      sits in, and from nothing else. Those exact false claims are the
      reason Project Reboot exists.
   4. A panel is an <article>, not an <a>. Several apps now have two real
      destinations (a store listing and their own site) and an anchor
      cannot contain another anchor. The links inside are real and
      tabbable, so nothing is lost but the whole-card click.

   Palette is the locked one. Type scale is the source page's, kept.
   ══════════════════════════════════════════════════════════════════════ */

:root {
  --ink:        #0a0a0a;
  --bone:       #faf8f3;
  --on-black:   #f5f1e8;
  --red:        #c8102e;
  /* #c8102e measures 3.4:1 on ink and fails AA, so it styles rules, fills
     and large display type only. #e8455a is the same hue at 5.4:1 and is
     what the rest of the estate ships for small red text. */
  --red-text:   #e8455a;

  --fg-rgb: 245, 241, 232;
  --bg-rgb: 10, 10, 10;
  --red-rgb: 200, 16, 46;
  --surface-2: #080808;

  --font-display: "Fraunces", Georgia, serif;
  --font-body:    "IBM Plex Sans", -apple-system, system-ui, sans-serif;
  --font-mono:    "JetBrains Mono", ui-monospace, monospace;

  --t-h1:     clamp(2.55rem, 5.4vw, 4.7rem);
  --t-h2:     clamp(2.1rem, 4.8vw, 3.7rem);
  --t-lede:   clamp(1rem, 1.55vw, 1.22rem);
  --t-body:   clamp(.95rem, 1.25vw, 1.05rem);
  --t-label:  .66rem;

  --s2: .75rem;
  --s3: 1.25rem;
  --s4: 2rem;
  --s5: 3.25rem;
  --pad-y: clamp(4rem, 10vh, 7.5rem);
  --pad-x: clamp(1.25rem, 5vw, 4.5rem);
  --rail:  1320px;

  --ease-out: cubic-bezier(0.16, 1, 0.3, 1);
}

*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
html {
  scroll-behavior: smooth;
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
  overflow-x: hidden; overflow-x: clip;
}
body {
  background: var(--ink);
  color: var(--on-black);
  font-family: var(--font-body);
  font-size: var(--t-body);
  line-height: 1.62;
  overflow-x: hidden; overflow-x: clip;
}
img, video { display: block; max-width: 100%; }
a { color: inherit; text-decoration: none; }
button { font: inherit; color: inherit; background: none; border: none; cursor: pointer; }
::selection { background: rgba(var(--red-rgb), .34); color: var(--bone); }
:focus-visible { outline: 2px solid var(--red-text); outline-offset: 4px; }

h1, h2 {
  font-family: var(--font-display);
  font-weight: 400;
  letter-spacing: -.022em;
  line-height: 1.04;
  color: var(--bone);
  text-wrap: balance;
}
h1 em, h2 em { font-style: italic; color: var(--red-text); }

.tn-rail {
  width: 100%;
  max-width: var(--rail);
  margin-inline: auto;
  padding-inline: var(--pad-x);
}
.tn-label {
  font-family: var(--font-mono);
  font-size: var(--t-label);
  letter-spacing: .22em;
  text-transform: uppercase;
  color: var(--red-text);
  margin-bottom: var(--s2);
}

/* ── ART LAYER CONTRACT (ART-GUIDE section 1) ── */
.tn-stage { position: relative; overflow: hidden; isolation: isolate; }
.tn-photo {
  position: absolute; inset: 0;
  width: 100%; height: 100%;
  object-fit: cover;
  z-index: 0;
  pointer-events: none;
}
.tn-scrim { position: absolute; inset: 0; z-index: 1; pointer-events: none; }
.tn-stage > *:not(.tn-photo):not(.tn-scrim) { position: relative; z-index: 2; }

/* ── REVEAL, the source page's system, unchanged in kind ── */
html.js-motion [data-reveal]:not(.tn-mask) {
  opacity: 0;
  transform: translateY(22px);
  transition: opacity .72s var(--ease-out), transform .72s var(--ease-out);
}
html.js-motion [data-reveal]:not(.tn-mask).is-visible { opacity: 1; transform: none; }
html.js-motion [data-reveal="1"] { transition-delay: .09s; }
html.js-motion [data-reveal="2"] { transition-delay: .18s; }
html.js-motion [data-reveal="3"] { transition-delay: .27s; }
.tn-mask { display: block; overflow: hidden; padding-bottom: .12em; margin-bottom: -.12em; }
html.js-motion .tn-mask > * {
  display: block;
  transform: translateY(105%);
  transition: transform 1.05s var(--ease-out);
}
html.js-motion .tn-mask.is-visible > * { transform: none; }

/* ══════════════════════════════════════════════════════════════════════
   1. HERO
   Ethan's Miami frame: the river channel at night, towers across the
   water, palms and the curved tower on the near bank. It is a portrait
   photograph, 1200x2133, so on a wide viewport `cover` shows a horizontal
   slice of it and object-position decides which. The slice is 31.6 percent
   of the picture's height on a 1440x810 hero, and 55 percent down lands it
   on source rows 38 to 70: the towers on the far bank, the channel itself,
   and the palms and the curved tower on the near one, which is the frame
   Ethan asked for. Anything higher loses the water, which is the subject.
   On a phone `cover` is height driven instead, so the whole portrait shows
   and this value does nothing there. The copy sits low left, over the water, which is the
   darkest quarter of the picture.
   ══════════════════════════════════════════════════════════════════════ */
.ap-hero {
  min-height: clamp(560px, 90svh, 940px);
  display: flex;
  align-items: flex-end;
  padding: calc(var(--pad-y) + 3rem) 0 var(--pad-y);
  background: var(--ink);
}
.ap-hero .tn-photo {
  opacity: .66;
  object-position: 50% 55%;
  filter: saturate(.94) contrast(1.06);
}
html.js-motion .ap-hero .tn-photo {
  animation: kenBurnsZoom 48s cubic-bezier(0.37, 0, 0.63, 1) infinite alternate;
  will-change: transform;
}
@keyframes kenBurnsZoom {
  from { transform: scale(1.03); }
  to   { transform: scale(1.11) translateY(-1.1%); }
}
/* One red radial, in the lower left corner, which is where the densest
   copy block sits. The left column is anchored by the 96deg layer, which
   is what holds the copy; the vertical layer stays open through the top
   half so the skyline and the water actually read, and only closes below
   70 percent where the index rows sit. Every run was measured against the
   composited pixels rather than the tokens, and against the glyph boxes
   rather than the element boxes: a block level paragraph spans the whole
   rail, so measuring its bounding rect samples picture the text never
   touches, which produced one false failure here before the rig was
   fixed. */
.ap-hero .tn-scrim {
  background:
    radial-gradient(ellipse 54% 46% at 6% 96%, rgba(var(--red-rgb), .2) 0%, transparent 64%),
    linear-gradient(to bottom, rgba(10,10,10,.6) 0%, rgba(10,10,10,.16) 24%, rgba(10,10,10,.4) 48%, rgba(10,10,10,.82) 70%, rgba(10,10,10,.96) 100%),
    linear-gradient(96deg, rgba(10,10,10,.92) 0%, rgba(10,10,10,.46) 52%, rgba(10,10,10,.06) 100%);
}
.ap-hero h1 {
  font-size: var(--t-h1);
  max-width: 15ch;
  text-shadow: 0 2px 30px rgba(10,10,10,.85);
}
.ap-hero-lede {
  margin-top: var(--s3);
  max-width: 56ch;
  font-size: var(--t-lede);
  line-height: 1.55;
  color: rgba(var(--fg-rgb), .88);
  text-shadow: 0 1px 16px rgba(10,10,10,.95);
}
.ap-index {
  margin-top: var(--s5);
  max-width: 720px;
  border-top: 1px solid rgba(var(--fg-rgb), .22);
}
/* The row for the page you are on is a <span>, not a link, so the layout rules
   sit on the child rather than on `a`: the first cut selected `a` only and the
   current row collapsed into a run-on line. */
.ap-index > * {
  display: grid;
  grid-template-columns: 3.4rem minmax(0, 1fr) auto;
  align-items: baseline;
  gap: 0 var(--s3);
  padding: .9rem 0;
  border-bottom: 1px solid rgba(var(--fg-rgb), .16);
  transition: padding-left .4s var(--ease-out);
}
.ap-index a:hover, .ap-index a:focus-visible { padding-left: .5rem; }
/* The current page's row is not a link and says so in colour rather than
   pretending to be one. No indent: shifting it broke the numeral column's
   alignment against the two rows either side of it. */
.ap-index [aria-current="page"] .ap-index-name { color: var(--red-text); }
.ap-index-n {
  font-family: var(--font-mono);
  font-size: var(--t-label);
  letter-spacing: .16em;
  color: var(--red-text);
  text-shadow: 0 1px 14px rgba(10,10,10,.98);
}
.ap-index-name {
  font-family: var(--font-display);
  font-size: clamp(1.15rem, 2.1vw, 1.6rem);
  color: var(--bone);
  text-shadow: 0 1px 14px rgba(10,10,10,.9);
}
.ap-index-note {
  font-family: var(--font-mono);
  font-size: var(--t-label);
  letter-spacing: .14em;
  text-transform: uppercase;
  color: rgba(var(--fg-rgb), .8);
  text-shadow: 0 1px 14px rgba(10,10,10,.95);
  white-space: nowrap;
}
@media (max-width: 620px) {
  .ap-index > * { grid-template-columns: 2.4rem minmax(0, 1fr); }
  .ap-index-note { grid-column: 2; white-space: normal; }
}

/* ── 1b. THE HERO ICON SET ──────────────────────────────────────────────
   Two staggered columns at ONE size. This is the composition Ethan chose
   after rejecting the ported descending arc that stood here: "needs to be
   way better deisgn", then "dispaly of the icons". The full account of why
   the arc failed is in scripts/sync-apps.mjs. The short version is that a
   curve drawn for four icons, given ten, overlapped itself, swept down into
   the index list, ate the whole first screen on a phone, and ranked the
   products by accident.

   UNIFORM MEANS UNIFORM. There is one size variable here and every icon
   reads it. No decay, no per row override, no exception. --icon tops out at
   84px because BRAT's master is 167 square, the largest App Store Connect
   exposes, and 84 CSS px is the biggest box a 2x screen fills from it
   without drawing it above its native size. scripts/sync-apps.mjs holds the
   same number and checks it against every row's iconMax, so the sheet and
   the guard cannot drift apart.

   THE FRAME IS WHAT MAKES TEN THINGS ONE SET. The source art genuinely
   disagrees with itself: Dispatch is a dark rounded square, Mr Indecisive a
   blue one, Tector a black circle on white, BRAT full bleed character art.
   So every icon sits in the same box, at the same radius, under the same
   hairline. The hairline is an ::after rather than an inset shadow on the
   tile, because an inset shadow paints UNDER the image and would vanish on
   every icon that fills its box, which is most of them.

   THE STAGGER IS A MARGIN, NEVER A TRANSFORM. Same rule the arc's centring
   followed, for the same reason: js/site.js hands transform to GSAP, and a
   resting transform is the one thing the fail-safe cannot have. Nothing in
   this block hides an icon, and in particular there is no
   `html.js-motion .ap-icon { opacity: 0 }`. That pattern is how the rest of
   this sheet does reveals and it is exactly wrong here: the motion gate is
   set by an inline <head> script, so a blocked or broken GSAP would leave
   the hero permanently empty. The finished set is what the HTML renders. JS
   only ever takes it apart and puts it back.
   ────────────────────────────────────────────────────────────────────── */
.ap-hero-grid {
  display: grid;
  grid-template-columns: minmax(0, 1fr) minmax(0, 47%);
  align-items: end;
  gap: clamp(2rem, 4vw, 4rem);
}
.ap-icons {
  --icon: clamp(56px, 5.8vw, 84px);
  --icon-gap: var(--s3);
  --icon-step: calc(var(--icon) + var(--icon-gap));
  display: flex;
  align-items: flex-start;
  justify-content: flex-end;
  gap: var(--icon-gap);
}
.ap-icons-col {
  list-style: none;
  display: flex;
  flex-direction: column;
  gap: var(--icon-gap);
}
.ap-icons-col--step { margin-top: calc(var(--icon-step) / 2); }
.ap-icon {
  position: relative;
  width: var(--icon);
  height: var(--icon);
  border-radius: 24%;
  background: var(--surface-2);
  box-shadow: 0 26px 56px -20px rgba(0, 0, 0, .85);
}
.ap-icon img {
  width: 100%;
  height: 100%;
  border-radius: inherit;
  object-fit: cover;
}
.ap-icon::after {
  content: "";
  position: absolute;
  inset: 0;
  border-radius: inherit;
  box-shadow: inset 0 0 0 1px rgba(var(--fg-rgb), .16);
  pointer-events: none;
}
@media (max-width: 879px) {
  /* One column, and the COPY leads it. The arc sat first here and pushed the
     headline to the bottom of the opening frame, which is half of what was
     rejected: the hero's job is the headline and the icons support it.

     The set turns through 90 degrees rather than shrinking to fit: two
     staggered ROWS of five, the same composition on the axis a phone
     actually has. Two columns of five would be a 190px wide, 550px tall
     sliver under the index, which is worse than either. 12.5vw keeps the
     whole run, half step stagger included, inside the rail down to 320. */
  .ap-hero-grid { grid-template-columns: 1fr; gap: clamp(1.75rem, 5vw, 3rem); }
  .ap-icons {
    --icon: min(84px, 12.5vw);
    --icon-gap: var(--s2);
    flex-direction: column;
  }
  .ap-icons-col { flex-direction: row; }
  .ap-icons-col--step {
    margin-top: 0;
    margin-left: calc(var(--icon-step) / 2);
  }
}

/* ══════════════════════════════════════════════════════════════════════
   2. THE JOURNEY. Ported from tnaado-media-next/apps.html.

   Default state is a real, always visible, hairline ruled index: every
   panel is normal document flow and every link inside it is reachable.
   `.journey.is-ready`, added by JS only once the motion gate AND GSAP both
   confirm, is a pure visual upgrade on the same markup: it repositions the
   panels as absolutely positioned, scrub driven, one-at-a-time fullscreen
   frames inside a tall pinned track, and paints `.journey-bg` with the
   current app's own accent.

   A no-JS visitor, a reduced motion visitor, and a visitor whose blocker
   ate the GSAP request all still get the complete, clickable list of all
   eleven apps. They just do not get the scrub.
   ══════════════════════════════════════════════════════════════════════ */
.journey { background: var(--ink); }
.journey-intro { position: relative; z-index: 1; }
.journey-intro-inner {
  max-width: var(--rail);
  margin: 0 auto;
  padding: clamp(3.5rem,7vw,6rem) var(--pad-x) clamp(1.6rem,3vw,2.4rem);
  display: grid;
  grid-template-columns: minmax(0,1fr) minmax(0,24rem);
  align-items: end;
  gap: var(--s4);
}
.journey-intro h2 { font-size: var(--t-h2); }
.journey-intro-rule {
  font-size: var(--t-body);
  line-height: 1.6;
  color: rgba(var(--fg-rgb), .82);
  padding-bottom: .3rem;
}
@media (max-width: 860px) {
  .journey-intro-inner { grid-template-columns: 1fr; align-items: start; }
}

.journey-track { position: relative; }
/* 80vh of scroll per panel, the source page's ratio (720vh for nine). */
.journey.is-ready .journey-track { height: calc(var(--panels, 4) * 80vh); }
.journey-stage { position: relative; }
.journey.is-ready .journey-stage {
  position: sticky;
  top: 0;
  height: 100vh;
  height: 100svh;
  overflow: hidden;
  display: flex;
  align-items: center;
  justify-content: center;
}
.journey-bg { display: none; }
.journey.is-ready .journey-bg { display: block; position: absolute; inset: 0; background: var(--ink); }
.journey-bg::after {
  content: '';
  position: absolute;
  inset: 0;
  background: radial-gradient(ellipse 70% 60% at 50% 45%, transparent 0%, rgba(10,10,10,.55) 60%, rgba(10,10,10,.94) 100%);
}

/* Numbered rail: a navigation enhancement, never the only way to reach a
   panel. Hidden entirely until JS wires the click handler, so a no-JS
   visitor is never shown an inert button list. Desktop only. */
.journey-rail { display: none; }
.journey.is-ready .journey-rail {
  display: flex;
  position: absolute;
  right: clamp(1rem,3vw,2.5rem);
  top: 50%;
  transform: translateY(-50%);
  flex-direction: column;
  gap: .9rem;
  z-index: 3;
}
@media (max-width: 820px) { .journey.is-ready .journey-rail { display: none; } }
.journey-rail-tick {
  display: block;
  padding: 0;
  font-family: var(--font-mono);
  font-size: .6rem;
  letter-spacing: .1em;
  color: rgba(var(--fg-rgb), .5);
  text-shadow: 0 1px 10px rgba(10,10,10,.9);
  transition: color .3s;
  white-space: nowrap;
}
.journey-rail-tick:hover, .journey-rail-tick:focus-visible { color: var(--bone); outline: none; }
.journey-rail-tick.is-active { color: var(--bone); }

.journey-panels { position: relative; border-top: 1px solid rgba(var(--fg-rgb), .14); }
.journey.is-ready .journey-panels { width: 100%; height: 100%; border-top: none; }

/* ── One panel, two shapes. The base rule is the real, always visible
   index row (media column, then text). `.journey.is-ready` overrides it to
   the fullscreen scrubbed frame. ── */
.journey-panel {
  position: relative;
  display: grid;
  grid-template-columns: auto minmax(0,1fr);
  align-items: center;
  gap: clamp(1.5rem,4vw,3rem);
  max-width: 1000px;
  margin: 0 auto;
  padding: clamp(1.8rem,4vw,2.6rem) clamp(1rem,4vw,1.5rem);
  border-bottom: 1px solid rgba(var(--fg-rgb), .14);
  text-align: left;
}
.journey.is-ready .journey-panel {
  position: absolute;
  inset: 0;
  display: flex;
  align-items: center;
  justify-content: center;
  gap: clamp(2rem,4vw,4rem);
  max-width: none;
  margin: 0;
  padding: 0 clamp(1.5rem,6vw,3rem);
  border-bottom: none;
  opacity: 0;
  visibility: hidden;
  pointer-events: none;
  z-index: 2;
}
@media (max-width: 760px) {
  .journey-panel { grid-template-columns: 1fr; gap: 1.1rem; }
  .journey.is-ready .journey-panel { flex-direction: column; gap: 1.2rem; }
}

.journey-panel-media { display: flex; align-items: center; justify-content: center; gap: .9rem; flex-shrink: 0; }
.journey-panel-shot {
  width: 118px;
  border-radius: 14px;
  overflow: hidden;
  border: 1px solid rgba(var(--fg-rgb), .16);
  box-shadow: 0 16px 40px rgba(0,0,0,.4);
  flex-shrink: 0;
  background: #111;
}
.journey-panel-shot img { display: block; width: 100%; height: auto; }
.journey-panel-shot:last-child { margin-top: 1.6rem; }
.journey.is-ready .journey-panel-shot { width: 150px; box-shadow: 0 24px 60px rgba(0,0,0,.5); }
.journey.is-ready .journey-panel-shot:last-child { margin-top: 2.2rem; }
/* Citadel is a Mac app, so its captures are landscape and need their own
   width or the real UI is squeezed into the 118px column sized for a
   phone. Two wide shots stack rather than sit side by side, which keeps
   the media column the same width as a single one and leaves the rail
   alone. */
.journey-panel-shot--wide { width: 300px; }
.journey.is-ready .journey-panel-shot--wide { width: 360px; }
.journey-panel-media--stack { flex-direction: column; }
.journey-panel-media--stack .journey-panel-shot:last-child { margin-top: .8rem; }
@media (max-width: 760px) { .journey-panel-media { justify-content: center; } }

/* THE ICON SLOT. An app with real screenshots shows them; an app without one
   shows its actual app icon in the same place, at app-tile scale. Ethan: "for
   brat use actual app icon please, where screenshots go, not word brat." The
   sizes are the source page's own icon rules, carried across unchanged.
   Tector's icon carries its own near-black ground, so it needs no plate behind
   it; the --light modifier is here for an icon drawn for a white tile, exactly
   as the source page used it. */
.journey-panel-icon {
  width: clamp(64px,7vw,84px);
  height: clamp(64px,7vw,84px);
  border-radius: 16px;
  overflow: hidden;
  border: 1px solid rgba(var(--fg-rgb), .16);
  box-shadow: 0 16px 40px rgba(0,0,0,.4);
  flex-shrink: 0;
}
.journey-panel-icon img { width: 100%; height: 100%; object-fit: cover; display: block; }
/* THE 84px CEILING, and why it is 84 rather than 167.
   BRAT's icon is 167x167 and that is the largest size App Store Connect will
   give: 220, 246, 512, 630 and 1024 were all requested from the asset template
   and every one came back 167. There is no master behind it here.
   The slot carries a 1px border, so a box of 84 renders the image at 82 CSS
   pixels, which is 164 device pixels on a 2x display: under the 167 the file
   actually has, so it is never resampled up. The source page's slot ran to
   116px, which asks a 2x display for 232 pixels from a 167px file. Smaller and
   sharp beats bigger and soft, which is the call the size was picked on.
   The ceiling sits on the slot, not on BRAT, so the two icons in this band stay
   the same size. Tector's is a vector and loses nothing by matching. */
.journey-panel-icon { max-width: 84px; max-height: 84px; }
.journey-panel-icon--light { background: #fff; padding: 8px; }
.journey-panel-icon--light img { object-fit: contain; border-radius: 10px; }
.journey.is-ready .journey-panel-icon {
  width: 84px;
  height: 84px;
  border-radius: 22px;
  box-shadow: 0 30px 80px rgba(0,0,0,.5);
}

.journey-panel-body { min-width: 0; }
/* Contrast scrim. `.journey-bg` is tweened per panel to each app's own
   accent, and two accents cross-fade during the scrub rather than snap, so
   mid-fade the copy can land on a pale blend of two colours. A dark radial
   anchored to the text block itself, rather than a flat overlay that would
   grey out every accent equally, keeps each panel's colour readable while
   guaranteeing the copy always sits on a dark backdrop. The negative
   margin cancels the padding so nothing shifts on screen. Deepened from
   the source page's .62 centre stop after measuring this page's own
   accents: BRAT's #8ACE00 is the brightest ground on the page and the
   description was reading 4.1:1 through the original recipe. */
.journey.is-ready .journey-panel-body {
  padding: 1.6rem 1.9rem;
  margin: -1.6rem -1.9rem;
  background: radial-gradient(ellipse 132% 152% at 32% 50%, rgba(0,0,0,.78) 0%, rgba(0,0,0,.6) 46%, rgba(0,0,0,.22) 76%, transparent 100%);
  border-radius: 22px;
}
.journey-panel-eyebrow {
  display: block;
  font-family: var(--font-mono);
  font-size: .62rem;
  letter-spacing: .16em;
  text-transform: uppercase;
  color: var(--red-text);
  margin-bottom: .5rem;
}
.journey.is-ready .journey-panel-eyebrow { font-size: .66rem; margin-bottom: .8rem; color: #ffd9de; }
/* The real company mark inline in the eyebrow rather than the word set in
   mono, which is Ethan's standing instruction for this page. Constrained
   by height, not width, so both marks carry the same optical weight on the
   line despite very different aspect ratios. Three things the source page
   learned the hard way and this keeps: the global `img { display:block }`
   reset splits the eyebrow onto three lines without `inline-block`; the
   red TNAADO mark disappears on the Dispatch panels because their accent
   IS that same red, so it is forced white in pinned mode; and the Labs
   wordmark's "Labs" half is dark grey, invisible on ink, so it is forced
   white everywhere. */
.journey-eyebrow-logo { display: inline-block; height: 12px; width: auto; vertical-align: -2px; margin: 0 .18em; }
.journey-eyebrow-logo--labs { height: 16px; vertical-align: -4px; }
.journey-eyebrow-logo, .journey-eyebrow-logo--labs { filter: brightness(0) invert(1); }
.journey.is-ready .journey-eyebrow-logo { height: 14px; vertical-align: -3px; }
.journey.is-ready .journey-eyebrow-logo--labs { height: 18px; vertical-align: -5px; }
/* `.journey-panel-owner` was a visually hidden span repeating the company name
   beside an alt="" mark. It is gone: the company name is the mark's ALT now, so
   there is one accessible name, announced once, and still present for anyone
   the image fails for. Two copies of a name was the pattern this whole pass
   exists to remove. */
.journey-panel-name {
  display: block;
  font-family: var(--font-display);
  font-size: clamp(1.15rem,2.2vw,1.5rem);
  font-weight: 400;
  line-height: 1.15;
  color: var(--bone);
  margin-bottom: .5rem;
}
.journey-panel-name em { color: var(--red-text); font-style: italic; }
.journey.is-ready .journey-panel-name { font-size: clamp(1.9rem,3.6vw,2.9rem); line-height: 1.08; margin-bottom: .8rem; }
.journey.is-ready .journey-panel-name em { color: #ffd9de; }
.journey-panel-desc {
  display: block;
  font-size: .9rem;
  line-height: 1.65;
  color: rgba(var(--fg-rgb), .82);
  max-width: 48ch;
}
.journey.is-ready .journey-panel-desc { font-size: clamp(.95rem,1.5vw,1.1rem); line-height: 1.7; max-width: 44ch; color: rgba(255,255,255,.94); }
.journey-panel-meta {
  display: block;
  margin-top: .7rem;
  font-family: var(--font-mono);
  font-size: .6rem;
  letter-spacing: .14em;
  text-transform: uppercase;
  color: rgba(var(--fg-rgb), .72);
}
.journey.is-ready .journey-panel-meta { color: rgba(255,255,255,.86); font-size: .62rem; }

/* Beta and launch date. Empty and hidden on every app until Ethan supplies
   the months. Nothing is substituted: no TBA, no season, no quarter, no
   guess. Fill `datetime` as YYYY-MM-01 and the text as "Month YYYY", then
   delete the hidden attribute. */
.journey-panel-when {
  display: flex;
  align-items: baseline;
  gap: .55rem;
  margin-top: .8rem;
  font-family: var(--font-mono);
  font-size: .62rem;
  letter-spacing: .16em;
  text-transform: uppercase;
}
.journey-panel-when[hidden] { display: none; }
.journey-panel-when-label { color: var(--red-text); }
.journey.is-ready .journey-panel-when-label { color: #ffd9de; }
.journey-panel-when-date { color: rgba(var(--fg-rgb), .9); }

.journey-panel-actions { display: flex; align-items: center; flex-wrap: wrap; gap: .9rem 1.6rem; margin-top: .9rem; }
.journey.is-ready .journey-panel-actions { margin-top: 1.3rem; }
/* THE APP STORE BADGE. Apple's artwork may not be recoloured, restretched
   or redrawn, so the only thing set here is a width: the SVG's own
   119.66x40 viewBox carries the ratio and height:auto keeps it. */
.journey-panel-badge { display: block; width: 120px; height: auto; max-width: 100%; }
.journey-panel-store { display: inline-flex; transition: opacity .25s var(--ease-out); }
.journey-panel-store:hover, .journey-panel-store:focus-visible { opacity: .84; }
.journey-panel-store[hidden] { display: none; }
.journey-panel-cta {
  display: inline-flex;
  align-items: center;
  gap: .5rem;
  min-height: 44px;
  font-family: var(--font-mono);
  font-size: .64rem;
  letter-spacing: .14em;
  text-transform: uppercase;
  color: var(--red-text);
  border-bottom: 1px solid rgba(var(--red-rgb), .5);
  transition: border-color .3s var(--ease-out);
}
.journey-panel-cta span { transition: transform .45s var(--ease-out); }
.journey-panel-cta:hover, .journey-panel-cta:focus-visible { border-bottom-color: var(--red-text); }
.journey-panel-cta:hover span, .journey-panel-cta:focus-visible span { transform: translateX(5px); }
.journey.is-ready .journey-panel-cta { color: #fff; border-bottom-color: rgba(255,255,255,.5); }
.journey.is-ready .journey-panel-cta:hover, .journey.is-ready .journey-panel-cta:focus-visible { border-bottom-color: #fff; }

.journey-hint { display: none; }
.journey.is-ready .journey-hint {
  display: block;
  position: absolute;
  bottom: clamp(1.5rem,4vw,2.5rem);
  left: 0; right: 0;
  text-align: center;
  z-index: 3;
  font-family: var(--font-mono);
  font-size: .62rem;
  letter-spacing: .18em;
  text-transform: uppercase;
  color: rgba(255,255,255,.55);
  text-shadow: 0 1px 12px rgba(10,10,10,.8);
}

/* ── REDUCED MOTION. The global nuke, then an explicit kill naming every
   animated layer on this page. The journey never reaches `.is-ready` at
   all here, because the motion gate in <head> is what unlocks it. ── */
@media (prefers-reduced-motion: reduce) {
  *, *::before, *::after {
    animation-duration: .01ms !important;
    animation-iteration-count: 1 !important;
    transition-duration: .01ms !important;
    transition-delay: 0ms !important;
  }
  html { scroll-behavior: auto; }
}
@media (prefers-reduced-motion: reduce) {
  .ap-hero .tn-photo { animation: none !important; transform: none !important; will-change: auto; }
}


/* ══════════════════════════════════════════════════════════════════════
   3. THE STORY PAGE, /<slug>. Ported section for section from
   tnaado-com/src/app/apps/[slug]/page.tsx, which is the page Ethan asked
   for by name. Its own header comment states the brief and this keeps it:

     the page does NOT sell the app, because each app has its own domain
     and its own marketing site, and the hero's largest element is the
     link out to it. What this page sells is the studio.

   So the hero is a link out with a facts rail beside it, then why it
   exists, then the Idea to Built to Live arc, then the way out. There is
   NO product screenshot unless a real capture exists in this repo, which
   is the ban the source page records; `.st-screens` renders only what
   scripts/sync-apps.mjs has confirmed on disk.

   TWO COLOURS, AND THE DIFFERENCE MATTERS.
   `--app-accent` is the app's own colour, set per page by the generator.
   It paints the hero glow, which is a large low alpha wash where any
   colour reads. It is NEVER used for text: Citadel's #1F3A57 measures
   1.69:1 on ink and WILDIN's #C9A46A is the only accent on the roster
   that clears AA unassisted. `--app-tone` is the same hue and saturation
   lifted until it clears 5:1 on ink, derived by the generator and printed
   into each page with the measured ratio in a comment. Small type uses
   that. The target is 5 and not 4.5 because the hero photograph sits
   under this type, so the tone needs headroom above the line rather than
   to land exactly on it.
   ══════════════════════════════════════════════════════════════════════ */

/* ── HERO ──
   The photograph is the same Miami frame the three band pages carry, but
   it runs far quieter here: this hero is about the app, not the city, and
   the facts rail's 10px mono labels are the fragile element. At .22 under
   a .86 veil the picture contributes under three percent of the composited
   luminance behind any glyph, which is what lets --app-tone hold its
   measured ratio over a photograph rather than over a flat colour. */
.st-hero {
  min-height: clamp(520px, 82svh, 880px);
  display: flex;
  flex-direction: column;
  justify-content: center;
  padding: calc(var(--pad-y) + 3.5rem) 0 var(--pad-y);
  background: var(--ink);
}
/* COLUMN, not row. The captures strip is a child of this section on purpose,
   because giving it a section of its own would need its own seam and an app
   with no captures would leave two seams stacked with nothing between them.
   With `align-items: center` on a row it became a flex SIBLING of the rail and
   sat beside the copy instead of under it. */
.st-hero .tn-photo { opacity: .62; object-position: 50% 55%; filter: saturate(.9) contrast(1.04); }
/* The veil is directional, which is the source page's own reasoning carried
   across: the facts rail's 10px mono labels are the fragile element, so the
   heavy end sits on the right and the headline takes the sheer end. The left
   column still gets its own wash because the eyebrow there is 10px mono too.
   Every stop was set by measuring composited glyph pixels, not by eye. */
.st-hero .tn-scrim {
  background:
    linear-gradient(to bottom, rgba(10,10,10,.66) 0%, rgba(10,10,10,.2) 30%, rgba(10,10,10,.44) 64%, rgba(10,10,10,.95) 100%),
    linear-gradient(94deg, rgba(10,10,10,.92) 0%, rgba(10,10,10,.44) 48%, rgba(10,10,10,.1) 100%);
}
/* One glow, keyed to the app's own accent rather than brand red. This is the
   app's page, so the app's colour leads. Decorative only, and it sits under
   the copy rather than behind it. */
/* It also carries .tn-scrim, which is what keeps it out of
   `.tn-stage > *:not(.tn-photo):not(.tn-scrim)` and therefore behind the copy
   rather than promoted on top of it. */
.st-glow {
  background: radial-gradient(ellipse 58% 52% at 80% 34%, color-mix(in srgb, var(--app-accent) 24%, transparent) 0%, color-mix(in srgb, var(--app-accent) 7%, transparent) 42%, transparent 72%);
}
@supports not (background: color-mix(in srgb, red 10%, transparent)) {
  .st-glow { background: none; }
}

.st-hero-rail {
  display: grid;
  grid-template-columns: minmax(0, 7fr) minmax(0, 4fr);
  align-items: center;
  gap: clamp(2.5rem, 5vw, 5rem);
}
@media (max-width: 940px) {
  .st-hero-rail { grid-template-columns: 1fr; gap: var(--s4); }
}

/* Inline text, not a flex row. As a wrapping flex row the separators led the
   second line and the rule sat on its own, which is not a line of type. */
.st-eyebrow {
  display: block;
  font-family: var(--font-mono);
  font-size: var(--t-label);
  line-height: 2;
  letter-spacing: .18em;
  text-transform: uppercase;
  color: var(--app-tone, var(--red-text));
  text-shadow: 0 1px 14px rgba(10,10,10,.95);
}
.st-rule {
  display: inline-block;
  width: 2.5rem;
  height: 1px;
  margin-right: .55rem;
  vertical-align: middle;
  background: var(--app-accent, var(--red));
}
.st-hero h1 { font-size: var(--t-h1); margin-top: var(--s3); max-width: 14ch; text-shadow: 0 2px 30px rgba(10,10,10,.85); }
.st-tagline {
  margin-top: var(--s3);
  max-width: 30ch;
  font-family: var(--font-display);
  font-style: italic;
  font-size: clamp(1.2rem, 2.1vw, 1.7rem);
  line-height: 1.32;
  color: rgba(var(--fg-rgb), .92);
  text-shadow: 0 1px 16px rgba(10,10,10,.95);
}

/* THE LARGEST ACTION ON THE PAGE. Every other control on this hero is a
   label; this is the door out to the product's own address, which is the
   whole reason the source page exists in this shape. */
.st-visit {
  display: inline-flex;
  backdrop-filter: blur(2px);
  align-items: center;
  gap: var(--s3);
  margin-top: var(--s4);
  padding: 1.05rem 1.35rem;
  border: 1px solid rgba(var(--fg-rgb), .24);
  background: rgba(var(--fg-rgb), .04);
  transition: border-color .3s var(--ease-out), background-color .3s var(--ease-out);
}
.st-visit:hover, .st-visit:focus-visible { border-color: rgba(var(--fg-rgb), .55); background: rgba(var(--fg-rgb), .08); }
.st-visit-icon { width: 52px; height: 52px; border-radius: 24%; flex-shrink: 0; object-fit: cover; }
.st-visit-text { display: block; }
.st-visit-kicker {
  display: block;
  font-family: var(--font-mono);
  font-size: .6rem;
  letter-spacing: .18em;
  text-transform: uppercase;
  color: rgba(var(--fg-rgb), .78);
}
.st-visit-host {
  display: block;
  margin-top: .3rem;
  font-family: var(--font-display);
  font-size: clamp(1.05rem, 1.7vw, 1.3rem);
  color: var(--bone);
}
.st-visit-arrow {
  margin-left: .4rem;
  font-size: 1.5rem;
  color: rgba(var(--fg-rgb), .7);
  transition: transform .35s var(--ease-out), color .35s var(--ease-out);
}
.st-visit:hover .st-visit-arrow, .st-visit:focus-visible .st-visit-arrow { transform: translateX(5px); color: var(--bone); }

.st-badge { display: inline-flex; margin-top: var(--s3); margin-left: var(--s3); transition: opacity .25s var(--ease-out); }
.st-badge:hover, .st-badge:focus-visible { opacity: .84; }
.st-badge img { width: 120px; height: auto; }
@media (max-width: 560px) { .st-badge { margin-left: 0; display: flex; } }

/* No link out. The block that would carry it says why. */
.st-nosite {
  margin-top: var(--s4);
  max-width: 44ch;
  padding: 1rem 1.2rem;
  border-left: 2px solid var(--app-accent, var(--red));
  background: rgba(var(--fg-rgb), .04);
  font-size: .92rem;
  line-height: 1.6;
  color: rgba(var(--fg-rgb), .86);
}

/* THE FACTS RAIL. It carries the right hand column instead of a product
   screenshot, which is the source page's own answer to the screenshot ban. */
/* The rail sits on its own faint plate. It is the densest small type on the
   page and it is the half of the hero the photograph shows through most. */
.st-facts {
  padding: var(--s3) var(--s3) .35rem;
  margin: 0 calc(var(--s3) * -1);
  background: rgba(10,10,10,.66);
  backdrop-filter: blur(3px);
  border-radius: 4px;
}
.st-facts dl { border-top: 1px solid rgba(var(--fg-rgb), .26); }
.st-fact {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: var(--s3);
  padding: .85rem 0;
  border-bottom: 1px solid rgba(var(--fg-rgb), .18);
}
.st-fact dt {
  font-family: var(--font-mono);
  font-size: .62rem;
  letter-spacing: .16em;
  text-transform: uppercase;
  color: rgba(var(--fg-rgb), .78);
  flex-shrink: 0;
}
.st-fact dd { text-align: right; font-size: .88rem; color: rgba(var(--fg-rgb), .92); }
/* Built by prints the company's mark. Same rule as the eyebrow and the same
   height ratios, 1.2 and 1.6 times the line, given in em so they track this
   larger 14px `dd` instead of being pinned to the eyebrow's pixels. The rail
   sits on its own dark plate, so the mark is inked to the same .92 the text
   beside it carries rather than to pure white. */
.st-fact-mark {
  display: inline-block;
  height: 1.2em;
  width: auto;
  vertical-align: -.2em;
  filter: brightness(0) invert(1);
  opacity: .92;
}
.st-fact-mark--labs { height: 1.6em; vertical-align: -.4em; }
[data-theme="light"] .st-fact-mark { filter: brightness(0); opacity: .88; }
.st-fact-when {
  display: flex;
  align-items: baseline;
  gap: .55rem;
  margin-top: var(--s3);
  font-family: var(--font-mono);
  font-size: .62rem;
  letter-spacing: .16em;
  text-transform: uppercase;
}
.st-fact-when[hidden] { display: none; }
.st-fact-when-label { color: var(--app-tone, var(--red-text)); }

/* REAL CAPTURES. Inside the hero section deliberately: a section of its own
   would need its own seam, and an app with no captures would leave two seams
   stacked with nothing between them. */
.st-screens { width: 100%; margin-top: clamp(3rem, 6vw, 4.5rem); border-top: 1px solid rgba(var(--fg-rgb), .18); padding-top: var(--s4); }
.st-screens-label {
  font-family: var(--font-mono);
  font-size: var(--t-label);
  letter-spacing: .22em;
  text-transform: uppercase;
  color: var(--app-tone, var(--red-text));
}
.st-screens-list { list-style: none; display: flex; gap: var(--s3); margin-top: var(--s4); flex-wrap: wrap; }
.st-screens-list > li { width: 148px; }
.st-screens-list--wide > li { width: min(340px, 78vw); }
.st-screen {
  display: block;
  border-radius: 14px;
  overflow: hidden;
  border: 1px solid rgba(var(--fg-rgb), .16);
  background: #111;
  box-shadow: 0 16px 40px rgba(0,0,0,.45);
}
.st-screen img { width: 100%; height: auto; }
.st-screen-cap {
  display: block;
  margin-top: .7rem;
  font-family: var(--font-mono);
  font-size: .58rem;
  letter-spacing: .18em;
  text-transform: uppercase;
  color: rgba(var(--fg-rgb), .78);
}

/* ── WHY IT EXISTS ── */
.st-why { background: var(--surface-2); padding: var(--pad-y) 0; border-top: 1px solid rgba(var(--fg-rgb), .1); }
.st-why-grid { display: grid; grid-template-columns: minmax(0, 3fr) minmax(0, 9fr); gap: var(--s4); }
@media (max-width: 860px) { .st-why-grid { grid-template-columns: 1fr; gap: var(--s3); } }
.st-why-body { max-width: 62ch; }
.st-why h2 { font-size: var(--t-h2); }
.st-why-pitch { margin-top: var(--s4); font-size: var(--t-lede); line-height: 1.68; color: rgba(var(--fg-rgb), .86); }
.st-limits {
  margin-top: var(--s4);
  padding: var(--s3) 0 var(--s3) var(--s3);
  border-left: 2px solid var(--app-accent, var(--red));
}
.st-limits h3 {
  font-family: var(--font-mono);
  font-size: .62rem;
  font-weight: 500;
  letter-spacing: .18em;
  text-transform: uppercase;
  color: var(--app-tone, var(--red-text));
}
.st-limits p { margin-top: .8rem; font-size: .95rem; line-height: 1.66; color: rgba(var(--fg-rgb), .86); }

/* ── THE STORY ── */
.st-story { background: var(--ink); padding: var(--pad-y) 0; }
.st-story-head { max-width: 40ch; }
.st-story-head h2 { font-size: var(--t-h2); margin-top: var(--s2); }
.st-chapters { list-style: none; margin-top: var(--s5); }
.st-chapters > li {
  display: grid;
  grid-template-columns: minmax(0, 3fr) minmax(0, 9fr);
  gap: var(--s4);
  padding: var(--s4) 0;
  border-top: 1px solid rgba(var(--fg-rgb), .16);
}
@media (max-width: 860px) { .st-chapters > li { grid-template-columns: 1fr; gap: var(--s3); } }
.st-chapter-num {
  display: block;
  font-family: var(--font-mono);
  font-size: clamp(2rem, 4vw, 3rem);
  line-height: 1;
  letter-spacing: -.02em;
  color: var(--app-tone, var(--red-text));
}
.st-chapter-phase {
  display: block;
  margin-top: .7rem;
  font-family: var(--font-mono);
  font-size: .62rem;
  letter-spacing: .2em;
  text-transform: uppercase;
  color: rgba(var(--fg-rgb), .78);
}
.st-chapter-body { max-width: 62ch; }
.st-chapter-body h3 {
  font-family: var(--font-display);
  font-weight: 400;
  font-size: clamp(1.25rem, 2.4vw, 1.75rem);
  line-height: 1.2;
  letter-spacing: -.018em;
  color: var(--bone);
  text-wrap: balance;
}
.st-chapter-body p { margin-top: var(--s3); font-size: var(--t-body); line-height: 1.7; color: rgba(var(--fg-rgb), .86); }

/* ── CLOSE ── */
.st-close { background: var(--surface-2); padding: var(--pad-y) 0; border-top: 1px solid rgba(var(--fg-rgb), .1); }
.st-close-grid { display: grid; grid-template-columns: minmax(0, 6fr) minmax(0, 5fr); gap: clamp(2.5rem, 5vw, 4.5rem); }
@media (max-width: 940px) { .st-close-grid { grid-template-columns: 1fr; } }
.st-close h2 { font-size: var(--t-h2); max-width: 18ch; }
.st-close-line { margin-top: var(--s3); max-width: 44ch; font-size: var(--t-body); line-height: 1.68; color: rgba(var(--fg-rgb), .86); }
.st-close-actions { display: flex; flex-wrap: wrap; gap: .75rem; margin-top: var(--s4); }
.st-btn {
  display: inline-flex;
  align-items: center;
  gap: .5rem;
  min-height: 44px;
  padding: .7rem 1.4rem;
  border: 1px solid rgba(var(--fg-rgb), .26);
  font-size: .84rem;
  font-weight: 500;
  color: var(--bone);
  transition: border-color .3s var(--ease-out), background-color .3s var(--ease-out);
}
.st-btn:hover, .st-btn:focus-visible { border-color: rgba(var(--fg-rgb), .6); background: rgba(var(--fg-rgb), .06); }
/* Brand red as a FILL with white on it, which is the one place #c8102e is
   correct: it is a background here, not small text on ink. */
.st-btn--solid { background: var(--red); border-color: var(--red); color: #fff; }
.st-btn--solid:hover, .st-btn--solid:focus-visible { background: #a80d26; border-color: #a80d26; }

.st-more-label {
  font-family: var(--font-mono);
  font-size: var(--t-label);
  letter-spacing: .22em;
  text-transform: uppercase;
  color: rgba(var(--fg-rgb), .78);
}
.st-more ul { list-style: none; margin-top: var(--s3); border-top: 1px solid rgba(var(--fg-rgb), .18); }
.st-more li { border-bottom: 1px solid rgba(var(--fg-rgb), .14); }
.st-more a {
  display: flex;
  align-items: center;
  gap: var(--s3);
  padding: .8rem .5rem .8rem 0;
  transition: background-color .25s var(--ease-out), padding-left .35s var(--ease-out);
}
.st-more a:hover, .st-more a:focus-visible { background: rgba(var(--fg-rgb), .05); padding-left: .5rem; }
/* Direct child only. The app icon is a child of the row's anchor; the company
   mark is nested inside `.st-more-meta`, and a bare `.st-more img` outranked
   `.st-more-mark` on specificity and squashed it into a 34px square. */
.st-more a > img { width: 34px; height: 34px; border-radius: 24%; flex-shrink: 0; object-fit: cover; }
.st-more-text { min-width: 0; flex: 1; }
.st-more-name { display: block; font-size: .92rem; font-weight: 500; color: var(--bone); }
.st-more-meta {
  display: block;
  margin-top: .15rem;
  font-family: var(--font-mono);
  font-size: .58rem;
  letter-spacing: .12em;
  text-transform: uppercase;
  color: rgba(var(--fg-rgb), .76);
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
/* The owning company on a "more apps" row signs with its mark, the same rule
   the eyebrow above already follows and the footer's `.ef-labs-mark` follows.
   Constrained by HEIGHT and given in em, so it tracks the 9px mono line it sits
   on and so the two marks read as equals despite very different aspect ratios:
   the ratios here are the eyebrow's measured ones, 1.2 and 1.6 times the line.
   Opacity matches the neighbouring text's own .76, because a mark forced to
   pure white beside dimmed type reads as a different element rather than as
   the first half of the same line. */
.st-more-mark {
  display: inline-block;
  height: 1.2em;
  width: auto;
  vertical-align: -.2em;
  margin-right: .1em;
  flex-shrink: 0;
  filter: brightness(0) invert(1);
  opacity: .76;
}
.st-more-mark--labs { height: 1.6em; vertical-align: -.4em; }
[data-theme="light"] .st-more-mark { filter: brightness(0); opacity: .72; }

.st-more-arrow { flex-shrink: 0; color: rgba(var(--fg-rgb), .7); transition: transform .25s var(--ease-out); }
.st-more a:hover .st-more-arrow, .st-more a:focus-visible .st-more-arrow { transform: translateX(3px); }

/* The story link is on every journey panel, in every band: it is this site's
   own page, so it is never an availability claim and it is never absent. */
.journey-panel-cta--story { color: rgba(var(--fg-rgb), .88); border-bottom-color: rgba(var(--fg-rgb), .35); }
.journey-panel-cta--story:hover, .journey-panel-cta--story:focus-visible { border-bottom-color: var(--bone); }

@media (prefers-reduced-motion: reduce) {
  .st-hero .tn-photo { animation: none !important; transform: none !important; }
}
