/* Design tokens (guide section 2B). Locked: do not add colors or fonts. */
:root {
  --bg: #000000;
  --panel: #050505;
  --line: #2A2A2A;
  --line-soft: #1C1C1F;
  --tab-on: #1F1F22;
  --text: #FFFFFF;
  --text-2: #A1A1A6;
  --text-3: #8E8E93;
  --btn-line: #3A3A3A;
  --max: 1100px;
  --section-gap: clamp(96px, 12vw, 180px);
  --nav-h: 64px; /* layout variable, not a token: fixed bar height */
}

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

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

body {
  margin: 0;
  padding-top: var(--nav-h);
  background: var(--bg);
  color: var(--text);
  font-family: "Geist", system-ui, sans-serif;
  font-size: 17px;
  line-height: 1.6;
  -webkit-font-smoothing: antialiased;
}

img {
  max-width: 100%;
  height: auto;
}

/* Typography: Geist 400/500 only, no uppercase labels. */
h1,
h2,
h3 {
  margin: 0;
  font-weight: 500;
}

h1 {
  font-size: clamp(40px, 6vw, 72px);
  line-height: 1.05;
  letter-spacing: -0.03em;
  max-width: 18ch;
}

h2 {
  font-size: clamp(28px, 3.5vw, 44px);
  line-height: 1.1;
  letter-spacing: -0.02em;
  margin-bottom: 32px;
}

h3 {
  font-size: 18px;
}

p {
  margin: 0 0 16px;
  max-width: 70ch;
}

a {
  color: inherit;
  text-decoration: underline;
  text-decoration-color: var(--text-3);
  text-underline-offset: 3px;
}

a:hover {
  text-decoration-color: currentColor;
}

/* Only Geist 400/500 is loaded (no 700), so bold text stays at 500. */
strong {
  font-weight: 500;
}

/* Geist Mono only for technical data (tool lists, stacks). */
code,
.mono {
  font-family: "Geist Mono", ui-monospace, monospace;
  font-size: 13px;
  color: var(--text-3);
}

small,
.small {
  font-size: 14px;
  color: var(--text-2);
}

/* Layout */
.wrap {
  max-width: var(--max);
  margin: 0 auto;
  padding: 0 24px;
}

section {
  padding-top: var(--section-gap);
}

main {
  padding-bottom: var(--section-gap);
}

/* Fixed bar never covers the element being scrolled to (WCAG 2.4.11). */
section,
:target {
  scroll-margin-top: var(--nav-h);
}

a,
button,
summary {
  scroll-margin-top: calc(var(--nav-h) + 8px);
}

/* Star canvas: fixed behind the content. z-index -1 paints above the body
   background because it propagates to the viewport. Needs no html.js gate: without
   JS it is an empty bitmap over --bg. */
#sky {
  position: fixed;
  inset: 0;
  width: 100%;
  height: 100%;
  z-index: -1;
  pointer-events: none;
}

/* Icon sprite: kept in the render tree so <use> resolves; visually collapsed. */
.sprite {
  position: absolute;
  width: 0;
  height: 0;
  overflow: hidden;
}

.icon {
  display: inline-block;
  width: 20px;
  height: 20px;
  vertical-align: middle;
}

.visually-hidden {
  position: absolute;
  width: 1px;
  height: 1px;
  margin: -1px;
  padding: 0;
  overflow: hidden;
  clip: rect(0, 0, 0, 0);
  white-space: nowrap;
  border: 0;
}

/* Buttons: capsules, no decorative arrows. */
.btn-primary,
.btn-secondary {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  /* A flex container drops the whitespace between its children, so the CV button's
     label and its status tag need an explicit gap to stay two words apart. */
  gap: 6px;
  min-height: 40px;
  padding: 0 20px;
  border-radius: 999px;
  font: inherit;
  font-size: 14px;
  font-weight: 500;
  text-decoration: none;
  cursor: pointer;
  transition: background-color 0.2s, color 0.2s, border-color 0.2s, opacity 0.2s;
}

.btn-primary {
  background: var(--text);
  color: var(--bg);
  border: 1px solid var(--text);
}

.btn-primary:hover {
  opacity: 0.88;
}

.btn-secondary {
  background: transparent;
  color: var(--text);
  border: 1px solid var(--btn-line);
}

.btn-secondary:hover {
  border-color: var(--text-2);
}

/* CV (D1): while assets/cv/ has no PDF, the button is served disabled from the HTML, so
   this rule carries no html.js guard — it has to look the same with and without scripts.
   And the dimming uses tokens, never opacity: tests/base.test.mjs requires every rule that
   declares opacity to start with "html.js ", and :disabled is not among its exceptions.
   --text-2 over --bg also keeps more contrast than a low opacity would. */
.btn-secondary:disabled {
  color: var(--text-2);
  border-color: var(--line);
  cursor: default;
}

/* #cv (issue #7): the button row. Not .actions — that selector is .hero .actions,
   descendant of the hero, so a bare .actions class outside it would be dead CSS. */
.cv-actions {
  margin: 0;
}

/* Trayectoria (guide §8): an ordered list of milestones, separated by the same divider
   .edu-block already uses. No .panel: five identical stacked cards are exactly the pattern
   §2B "Qué evitar" rules out. No .reveal and no data-parallax: §2B limits appear-once
   animation to the projects panel and the course cards. */
.journey {
  display: grid;
  gap: 32px;
  margin: 0;
  padding: 0;
  list-style: none;
}

.journey-item {
  padding-top: 24px;
  border-top: 1px solid var(--line);
}

/* Period and entity/note: both --text-2, 14px, like .project-meta. Each fragment the
   guide separates with a middle dot becomes its own element, never a "·"-joined string. */
.journey-period {
  margin: 0 0 4px;
  font-size: 14px;
  color: var(--text-2);
}

.journey-meta {
  margin: 4px 0 8px;
  font-size: 14px;
  color: var(--text-2);
}

.journey-body {
  margin: 0;
  color: var(--text-2);
}

/* Cursos (guide §9): two .panel cards, one column in mobile and two from 761px. These are
   the only elements this change authorises for the one-time .reveal appearance. */
.courses-list {
  display: grid;
  gap: 28px;
}

.course-card {
  margin: 0;
}

/* Origin, status and period separated by spaces, never a middle dot or a dash (guide §2B). */
.course-meta {
  display: flex;
  flex-wrap: wrap;
  gap: 12px;
  font-size: 14px;
  color: var(--text-2);
}

.course-card .facts {
  margin-top: 16px;
}

/* Contacto (guide §10): three channels, one row each. No form: the CSP declares
   form-action 'none' and the guide asks for direct channels. */
.contact-intro {
  color: var(--text-2);
}

.contact-list {
  display: grid;
  gap: 8px;
  margin: 24px 0 0;
  padding: 0;
  list-style: none;
}

/* 40px-tall row around icon + text: same tactile target as .nav-menu-list a (WCAG 2.5.8). */
.contact-list a {
  display: inline-flex;
  align-items: center;
  gap: 8px;
  min-height: 40px;
}

/* Footer (D2): sits outside <main>, so it is a real contentinfo landmark. main already
   declares padding-bottom: var(--section-gap), so the footer only needs its own divider
   and vertical breathing room. It never repeats the contact links, which sit right above. */
.site-footer {
  padding: 32px 0 48px;
  border-top: 1px solid var(--line-soft);
  font-size: 14px;
  color: var(--text-2);
}

.site-footer p {
  margin: 0;
}

.btn-sm {
  min-height: 32px;
  padding: 0 14px;
}

/* Panel: flat, no shadow or gradient. */
.panel {
  background: var(--panel);
  border: 1px solid var(--line);
  border-radius: 16px;
  padding: clamp(20px, 3vw, 36px);
  box-shadow: none;
}

/* Tab capsule: .tabs is the tablist, .tab each role="tab". */
.tabs {
  display: inline-flex;
  gap: 2px;
  padding: 4px;
  border: 1px solid var(--line);
  border-radius: 999px;
  background: rgba(0, 0, 0, 0.6);
}

.tab {
  min-height: 24px;
  padding: 8px 18px;
  border: 0;
  border-radius: 999px;
  background: transparent;
  color: var(--text-2);
  font: inherit;
  font-size: 15px;
  cursor: pointer;
}

.tab:hover {
  color: var(--text);
}

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

/* Keyboard focus: white on black, 21:1 (WCAG 1.4.11). Never blanket-removed. */
a:focus-visible,
button:focus-visible,
[role="tab"]:focus-visible,
input:focus-visible,
textarea:focus-visible,
select:focus-visible,
summary:focus-visible {
  outline: 2px solid var(--text);
  outline-offset: 2px;
}

/* Skip link: off-screen by `top` (never display/opacity/transform), so it
   needs neither JavaScript nor the html.js guard to reach the focused state. */
.skip {
  position: fixed;
  top: -64px;
  left: 8px;
  z-index: 30;
  padding: 8px 16px;
  border-radius: 999px;
  background: var(--text);
  color: var(--bg);
  font-size: 14px;
  font-weight: 500;
  text-decoration: none;
  transition: top 0.2s;
}

.skip:focus {
  top: 8px;
}

/* Fixed navigation (guide 2B: "fija"). Opaque --bg, one landmark inside. */
.nav {
  position: fixed;
  top: 0;
  left: 0;
  right: 0;
  z-index: 20;
  min-height: var(--nav-h);
  background: var(--bg);
  border-bottom: 1px solid var(--line-soft);
}

.nav-in {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 24px;
  /* --nav-h counts the 1px bottom border of .nav, so the row gives it up. */
  min-height: calc(var(--nav-h) - 1px);
}

.brand {
  font-size: 16px;
  font-weight: 500;
  text-decoration: none;
}

.nav-links,
.nav-menu-list {
  list-style: none;
  margin: 0;
  padding: 0;
}

.nav-links {
  display: flex;
  gap: 24px;
  font-size: 14px;
}

.nav-links a,
.nav-menu-list a:not(.btn-primary) {
  color: var(--text-2);
  text-decoration: none;
}

.nav-links a:hover,
.nav-menu-list a:not(.btn-primary):hover {
  color: var(--text);
}

.nav-right {
  display: flex;
  align-items: center;
  gap: 14px;
  font-size: 14px;
  color: var(--text-2);
}

.lang {
  display: inline-flex;
  align-items: center;
  gap: 2px;
}

.lang button {
  min-width: 24px;
  min-height: 24px;
  padding: 2px 4px;
  border: 0;
  border-radius: 4px;
  background: none;
  color: var(--text-2);
  font: inherit;
  cursor: pointer;
}

.lang button:hover {
  color: var(--text);
}

.lang button[aria-pressed="true"] {
  color: var(--text);
  text-decoration: underline;
  text-underline-offset: 3px;
}

/* 32px box around the 20px icon: WCAG 2.5.8 target size. */
.nav-ico {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 32px;
  height: 32px;
  border-radius: 8px;
  color: var(--text-2);
  text-decoration: none;
}

.nav-ico:hover {
  color: var(--text);
}

/* Mobile menu: native <details>, so it opens and closes without JavaScript.
   The panel hangs from .nav (the nearest positioned ancestor). .edu-plan (guide
   §5.3, the full study plan) shares this summary styling: zero duplication for
   an identical toggle affordance. */
.nav-menu > summary,
.edu-plan > summary,
.skills-tech > summary {
  display: inline-flex;
  align-items: center;
  list-style: none;
  cursor: pointer;
  min-height: 32px;
  padding: 4px 10px;
  border: 1px solid var(--btn-line);
  border-radius: 999px;
  font-size: 14px;
  color: var(--text-2);
}

.nav-menu > summary::-webkit-details-marker,
.edu-plan > summary::-webkit-details-marker,
.skills-tech > summary::-webkit-details-marker {
  display: none;
}

.nav-menu > summary:hover,
.edu-plan > summary:hover,
.skills-tech > summary:hover {
  color: var(--text);
}

.nav-menu > .nav-menu-list {
  position: absolute;
  left: 0;
  right: 0;
  top: 100%;
  display: flex;
  flex-direction: column;
  gap: 4px;
  padding: 8px 24px 20px;
  max-height: calc(100vh - var(--nav-h));
  overflow-y: auto;
  background: var(--bg);
  border-bottom: 1px solid var(--line-soft);
}

.nav-menu-list a:not(.btn-primary) {
  display: inline-flex;
  align-items: center;
  gap: 8px;
  min-height: 40px;
}

.nav-menu-list .btn-primary {
  margin-top: 8px;
}

/* Hero: centred, above the fold. .hero (0,1,0) beats section (0,0,1) for padding-top.
   The two vertical paddings are named custom properties, not bare values, because the
   sticky-stage rules below (issue #10) override them to 0 while html.js pins the scene,
   and the reduced-motion query restores them when the pin collapses. */
.hero {
  --hero-pad-top: clamp(96px, 16vh, 180px);
  --hero-pad-bottom: clamp(48px, 10vh, 120px);
  padding-top: var(--hero-pad-top);
  padding-bottom: var(--hero-pad-bottom);
  padding-inline: 24px;
  text-align: center;
}

.hero .kicker {
  margin: 0 auto 18px;
  font-size: 15px;
  color: var(--text-2);
}

.hero h1 {
  margin: 0 auto 24px;
}

.hero .lead {
  max-width: 46ch;
  margin: 0 auto 32px;
  font-size: 18px;
  color: var(--text-2);
}

.hero .actions {
  display: flex;
  flex-wrap: wrap;
  justify-content: center;
  gap: 12px;
}

.hero .status {
  margin: 28px auto 0;
  font-size: 14px;
  color: var(--text-2);
}

/* About (guide §4): photo-optional grid. No <img> ships yet; the two-column
   split applies only once a future .about-photo exists (see :has() below). */
.about-body {
  max-width: 70ch;
}

.about-body p {
  color: var(--text-2);
}

.about-photo {
  display: block;
  width: 100%;
  aspect-ratio: 4 / 5;
  border-radius: 16px;
  border: 1px solid var(--line);
  box-shadow: none;
}

.facts {
  margin: 24px 0 0;
}

.fact {
  display: grid;
  grid-template-columns: 9rem minmax(0, 1fr);
  gap: 4px 16px;
  padding: 8px 0;
  border-top: 1px solid var(--line-soft);
}

.fact:first-child {
  border-top: 0;
}

.fact dt,
.fact dd {
  margin: 0;
}

/* A value with no break opportunity (a URL such as nics.uma.es/Blockchain) would
   otherwise overflow its minmax(0, 1fr) column and scroll the page sideways at 360px. */
.fact dd {
  overflow-wrap: anywhere;
}

.fact dt {
  color: var(--text-2);
}

@media (max-width: 760px) {
  .about-photo {
    max-width: 320px;
  }
}

@media (min-width: 761px) {
  .about:has(.about-photo) {
    display: grid;
    grid-template-columns: 5fr 7fr;
    align-items: start;
    gap: 48px;
  }
}

/* Education (guide §5): the degree card reuses .panel and .facts/.fact. */
.edu-card {
  margin-top: 32px;
}

/* Plain columns, not four identical cards: a border-top divider keeps the
   blocks visually light while 1/2/4-column widths add breathing room. */
.edu-blocks {
  display: grid;
  grid-template-columns: 1fr;
  gap: 32px;
  margin-top: 48px;
}

.edu-block {
  padding-top: 24px;
  border-top: 1px solid var(--line);
}

.edu-block h3 {
  margin-bottom: 12px;
}

.edu-list {
  display: grid;
  gap: 8px;
  margin: 0;
  padding: 0;
  list-style: none;
}

.edu-list li {
  display: grid;
  grid-template-columns: 1.5em minmax(0, 1fr);
  align-items: baseline;
}

.edu-mark {
  color: var(--text-2);
}

.edu-name {
  grid-column: 2;
}

@media (min-width: 761px) {
  .edu-blocks {
    grid-template-columns: repeat(2, 1fr);
  }
}

@media (min-width: 1024px) {
  .edu-blocks {
    grid-template-columns: repeat(4, 1fr);
  }
}

/* Full study plan (guide §5.3), folded behind a native <details>: no JS toggle needed. */
.edu-plan {
  margin-top: 48px;
}

.edu-plan-year {
  margin-top: 24px;
}

.edu-plan-year h3 {
  margin-bottom: 8px;
}

/* Subjects are newline-joined in the dictionary, not comma-joined, because some
   subject names already contain commas. pre-line renders each "\n" as a line break
   while still collapsing runs of ordinary spaces. */
.edu-plan-blob {
  white-space: pre-line;
  color: var(--text-2);
}

/* Skills (guide §6): one row per area, name left, techniques right, tools under them. */
.skills-area {
  display: grid;
  grid-template-columns: 200px minmax(0, 1fr);
  gap: 24px;
  padding: 22px 0;
  border-top: 1px solid var(--line);
}

.skills-area:last-child {
  border-bottom: 1px solid var(--line);
}

/* Automatic minimum size guard: the right column holds long mono tool lists,
   so the grid item must be allowed to shrink below its content's min size. */
.skills-area-body {
  min-width: 0;
}

.skills-list {
  display: grid;
  gap: 8px;
  margin: 0 0 12px;
  padding: 0;
  list-style: none;
}

.skills-tech > summary {
  gap: 8px;
  max-width: 100%;
  color: var(--text);
}

.skills-pending > summary {
  color: var(--text-2);
}

.skills-status {
  color: var(--text-3);
  font-size: 13px;
}

/* Only the unfinished projects carry a status word on their tab, the same rule the
   Competencias techniques, the CV button and the NVIDIA course card follow. The word is
   dimmed so the project name (or "Descargar CV", or the course title) stays the label; the
   "Estado:" prefix is read aloud but not shown. */
.tab-status,
.cv-status,
.course-status {
  color: var(--text-3);
  font-size: 13px;
}

.skills-detail {
  padding: 12px 0 4px 14px;
}

/* Reuses .facts/.fact (above); only the outer margin has to go. */
.skills-detail .facts {
  margin: 0;
}

.skills-tools {
  margin: 0;
}

/* Projects (guide §7): centred tab capsule, one static panel per project. The capsule
   ships `hidden` and initProjectsViewer() removes it, so a no-JS page has no dead
   controls. Never give .projects-tabs or .project-panel a `display` declaration: the
   UA's [hidden] { display: none } loses to any author display rule. */
.projects-tabs {
  text-align: center;
  margin-bottom: 28px;
}

.project-panel {
  margin-top: 28px;
}

/* Visual zone (guide §7.1): full panel width, 16:9, the working diagram centred inside.
   No overflow: hidden — the diagram must never be clipped. */
.project-visual {
  display: flex;
  align-items: center;
  justify-content: center;
  aspect-ratio: 16 / 9;
  padding: 16px;
  border: 1px solid var(--line-soft);
  border-radius: 12px;
}

.project-diagram {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  justify-content: center;
  gap: 10px;
}

/* Diagram boxes (guide §2B): black fill, --line-soft border, 8px radius. Drawn in HTML,
   never as an image. */
.project-node {
  display: grid;
  gap: 2px;
  padding: 10px 14px;
  background: var(--bg);
  border: 1px solid var(--line-soft);
  border-radius: 8px;
  font-size: 14px;
  text-align: center;
}

.project-arrow {
  color: var(--text-3);
}

.project-body {
  display: grid;
  grid-template-columns: minmax(0, 1.5fr) minmax(0, 1fr);
  gap: 32px;
  margin-top: 28px;
}

.project-body h3 {
  font-size: 26px;
  letter-spacing: -0.01em;
  margin-bottom: 6px;
}

.project-body p {
  color: var(--text-2);
}

/* Metadata: origin and status separated by a space, no middle dots or dashes (guide §2B). */
.project-meta {
  display: flex;
  flex-wrap: wrap;
  gap: 12px;
  font-size: 14px;
}

/* "Completado" reads in --text; every other state stays --text-2 (guide §2B). Shared with
   the Blockchain course card, the only other "Completado" state in the page. */
.project-status-done,
.course-status-done {
  color: var(--text);
}

/* The right column is ~300px wide, so the label/value grid stacks at every width. */
.project-side {
  margin: 0;
}

.project-side .fact {
  grid-template-columns: 1fr;
}

/* Responsive swap: the desktop list and the mobile <details> are never both
   shown. Width media queries are layout, not enhancement, so no html.js. */
@media (max-width: 760px) {
  :root {
    --nav-h: 56px;
  }

  .nav-in {
    gap: 16px;
  }

  .nav-right {
    margin-left: auto;
  }

  .nav-links,
  .nav-right .nav-ico,
  .nav-right .btn-primary {
    display: none;
  }

  /* Overrides the base .skills-area/.skills-detail .fact rules above; this block
     must sit after them in source order, since both share the same specificity. */
  .skills-area {
    grid-template-columns: 1fr;
    gap: 12px;
  }

  .skills-detail .fact {
    grid-template-columns: 1fr;
  }

  .project-body {
    grid-template-columns: 1fr;
    gap: 12px;
  }

  /* A wrapped four-box diagram needs more than 16:9 of a 312px column: the box grows
     instead of clipping. */
  .project-visual {
    aspect-ratio: auto;
    min-height: 200px;
    padding: 12px;
  }
}

@media (min-width: 761px) {
  .nav-menu {
    display: none;
  }

  /* Overrides the base .courses-list rule above; this override must sit after it in
     source order, the same pattern .skills-area already uses. */
  .courses-list {
    grid-template-columns: repeat(2, 1fr);
  }
}

/* Enhancement hooks: anything that hides or animates lives under html.js,
   so a missing or failing script degrades to the static render. */
/* Starts fully transparent (guide §2B: opacity 0 to 1). A partial value such as 0.2 leaves
   not-yet-revealed text visible at ~1.7:1, which Lighthouse reports as a contrast failure.
   Gated on .reveal-armed, which initReveal() adds only once every element is observed: if it
   never runs or fails first, nothing is hidden. */
html.js.reveal-armed .reveal {
  opacity: 0;
  transform: scale(0.94);
  transition: opacity 0.8s ease, transform 0.8s cubic-bezier(0.2, 0.8, 0.2, 1);
}

html.js.reveal-armed .reveal.in {
  opacity: 1;
  transform: none;
}

/* Scroll intro pinned stage (issue #10, design ADR-1). #hero grows tall enough to give a
   100vh scroll runway; .hero-stage stays sticky at the top of the viewport for exactly
   that runway, so the pin lasts one screen. Everything here lives under html.hero-armed,
   a class initHeroIntro() adds to <html> only after a successful split() (js/main.js),
   never merely under html.js: a capability guard clause, or split() failing/throwing,
   must never leave a 200vh runway with a plain, unanimated heading inside it (review
   follow-up, issue #10). Without JavaScript, or with JavaScript but no successful split,
   .hero-stage is a plain div with no declaration applied, and #hero sizes by its own
   content exactly as before this change. */
html.hero-armed #hero {
  min-height: 200vh;
  padding-top: 0;
  padding-bottom: 0;
}

/* padding-top: var(--nav-h) compensates the fixed nav (z-index: 20) so the content sits
   centred in the visible area below it, instead of top: var(--nav-h), which would push
   the whole stage further down. */
html.hero-armed #hero .hero-stage {
  position: sticky;
  top: 0;
  min-height: 100vh;
  padding-top: var(--nav-h);
  display: flex;
  flex-direction: column;
  justify-content: center;
}

/* Split title base states (issue #10 slice B1, design §4.4). Created only by
   initHeroIntro().split(), so there is no no-JS variant to worry about. No
   opacity/transform/filter here on purpose: the initial choreography values land in
   slice B2 as animation-fill-mode: both, so without that slice yet — or if it fails —
   the title reads complete and sharp. */
html.js .hero-title {
  display: block;
}

/* Word grouping is mandatory, not cosmetic: loose inline-block characters let the
   browser break the line inside a word, and the h1's max-width: 18ch (line 57) means
   the title always wraps (ADR-3, risk R11). */
html.js .hero-word {
  display: inline-block;
  white-space: nowrap;
}

html.js .hero-char {
  display: inline-block;
}

/* Tab fade (guide §7.1): a panel restarts its animations when `hidden` is removed, so
   the 180 ms fade needs no JavaScript. Fill-mode is deliberately left unset — with
   `both` the final opacity: 1 would be frozen onto the element and would permanently
   override html.js.reveal-armed .reveal above (slice E). */
html.js .project-panel {
  animation: project-fade 180ms ease-out;
}

/* Scroll-linked motion (guide §2B). Gated on the function value AND the dependent longhand:
   a partial implementation can support animation-timeline without animation-range and
   mistime the whole animation (research #1626 §2). animation-duration must be non-zero
   (Firefox requirement, research #1626 §3) and is declared AFTER the shorthand, because the
   shorthand resets duration to 0s and animation-timeline to auto. initParallax() yields to
   this branch by testing the identical condition string (js/main.js). */
@supports ((animation-timeline: view()) and (animation-range: 0% 100%)) {
  html.js [data-parallax] {
    animation: project-parallax linear both;
    animation-timeline: view();
    animation-range: entry 0% exit 100%;
    animation-duration: 1ms;
  }

  /* Intro phase 1 (0 -> 20vh, issue #10 slice B2, design ADR-5/§4.5): the title comes out
     of a blur as ONE layer, animation-fill-mode: both so the 0% keyframe (the legible
     floor, design ADR-4) is what a visitor who never scrolls actually sees. The characters
     stay still for the whole phase on purpose: a filter on a container whose children are
     moving forces the engine to re-rasterize the subtree every frame (design §6). */
  html.js #hero .hero-title {
    animation: hero-blur linear both;
    animation-timeline: scroll();
    animation-range: 0 20vh;
    animation-duration: 1ms;
  }

  /* Intro phase 2 (20vh -> 100vh): each character travels 25vh, offset by its normalised
     position --i (0..1) that split() writes through the CSSOM. The last character starts
     at 75vh and ends exactly at 100vh, the end of the pin runway (design §4.5/ADR-6). */
  html.js #hero .hero-char {
    animation: hero-char linear both;
    animation-timeline: scroll();
    animation-range: calc(20vh + var(--i) * 55vh) calc(45vh + var(--i) * 55vh);
    animation-duration: 1ms;
  }
}

@keyframes project-fade {
  from {
    opacity: 0;
  }
  to {
    opacity: 1;
  }
}

@keyframes project-parallax {
  0% {
    transform: translateY(40px) scale(0.96);
  }
  50% {
    transform: translateY(0) scale(1);
  }
  100% {
    transform: translateY(-40px) scale(0.96);
  }
}

/* blur(0px), not none: none is not a value in the <filter-value-list>, so the engine
   would interpolate against the identity through a separate rule; blur(0px) interpolates
   numerically and is what the same engine produces at the end of the range anyway. */
@keyframes hero-blur {
  0% {
    filter: blur(4px);
  }
  100% {
    filter: blur(0px);
  }
}

@keyframes hero-char {
  0% {
    opacity: 0.6;
    transform: translateY(14px);
  }
  100% {
    opacity: 1;
    transform: translateY(0);
  }
}

/* Parallax offset halved below 760px (guide §2B); the rAF branch halves the same constant
   in initParallax()'s offset(). Redefining @keyframes project-parallax inside a media
   query replaces the earlier, unconditional definition only when the query matches — and
   only because this block sits AFTER the base @keyframes project-parallax above in source
   order (same rule as any other max-width override, hard constraint 5). */
@media (max-width: 760px) {
  /* With the status words the three tabs no longer fit on one line at 360px, so the
     capsule wraps instead of pushing the page sideways. */
  .tabs {
    flex-wrap: wrap;
    justify-content: center;
  }

  /* Intro pin runway halved below 760px (issue #10), same mitigation pattern as the
     parallax offset above: half the scroll cost on a small screen. Same html.hero-armed
     gate as the base rule above: no runway when the pin never armed. */
  html.hero-armed #hero {
    min-height: 150vh;
  }

  @keyframes project-parallax {
    0% {
      transform: translateY(20px) scale(0.96);
    }
    50% {
      transform: translateY(0) scale(1);
    }
    100% {
      transform: translateY(-20px) scale(0.96);
    }
  }

  /* Intro blur halved below 760px (issue #10 slice B2), same mitigation pattern as the
     parallax offset above: 2px is the composition cost that weighs the most on a
     mid-range phone. Must sit after the unconditional @keyframes hero-blur above in
     source order (same rule as @keyframes project-parallax's mobile redefinition). */
  @keyframes hero-blur {
    0% {
      filter: blur(2px);
    }
    100% {
      filter: blur(0px);
    }
  }

  @keyframes hero-char {
    0% {
      opacity: 0.6;
      transform: translateY(7px);
    }
    100% {
      opacity: 1;
      transform: translateY(0);
    }
  }

  /* Mobile pin runway is 150vh - 100vh = 50vh (half the desktop 100vh), so the
     animation-range fractions from the @supports block above are halved too: the last
     character still ends exactly at the end of the runway (23vh + 27vh = 50vh). */
  html.js #hero .hero-title {
    animation-range: 0 10vh;
  }

  html.js #hero .hero-char {
    animation-range: calc(10vh + var(--i) * 27vh) calc(23vh + var(--i) * 27vh);
  }
}

/* Short viewport (landscape phone, phone up to 760px tall, short window: measured at
   360x640 the status line ended 23px below the fold): the hero content
   can be taller than the screen, and a sticky stage taller than the viewport keeps its
   lower part (actions, status) below the fold for the whole runway. No pin there: the
   hero sizes by its content, exactly like the reduced-motion collapse below. Placed after
   the 760px block so it wins over its 150vh runway. Same html.hero-armed selector and
   specificity as the pin rules above, so it is this later source position that makes it
   win, not a stronger selector. */
@media (max-height: 600px), (max-width: 760px) and (max-height: 760px) {
  html.hero-armed #hero {
    min-height: 0;
    padding-top: var(--hero-pad-top);
    padding-bottom: var(--hero-pad-bottom);
  }

  html.hero-armed #hero .hero-stage {
    position: static;
    min-height: 0;
    padding-top: 0;
  }

  /* Not pinned here (design drift, issue #10 slice B2 review): with no scroll runway to
     drive the choreography, a scroll() timeline with no scroller argument would measure
     progress against the whole document instead of the hero, resolving the title against
     unrelated content further down the page. Freeze it in its finished state instead,
     same contract the rAF fallback's short() check applies in js/main.js. */
  html.js #hero .hero-title,
  html.js #hero .hero-char {
    animation: none;
    animation-timeline: none;
    filter: none;
    opacity: 1;
    transform: none;
  }
}

/* Reduced motion: no smooth scroll, no reveal, no transitions. */
@media (prefers-reduced-motion: reduce) {
  html {
    scroll-behavior: auto;
  }

  html.js.reveal-armed .reveal {
    opacity: 1;
    transform: none;
    transition: none;
  }

  /* The global animation-duration: 0.01ms below does NOT stop a scroll-driven animation:
     its progress comes from the timeline, not the clock. Every branch is shut off
     explicitly (research #1626 gap #2, risk R1; issue #10 slice B1 design §4.8): the
     split title's two nodes join the list alongside parallax and the pinned stage, even
     though their own scroll-driven animation rules only land in slice B2 — this is the
     one place that contract is declared ahead of time so B2 does not have to touch it. */
  html.js [data-parallax],
  html.js #hero,
  html.js #hero .hero-title,
  html.js #hero .hero-char {
    animation: none;
    animation-timeline: none;
  }

  /* No pin means no runway: #hero drops back to sizing by its own content and its
     stage leaves the sticky flow, so reduced motion never leaves a screen of empty
     scroll where nothing happens (issue #10, design §4.8). Same html.hero-armed
     selector and specificity as the pin rules above; initHeroIntro() also removes the
     class itself on the live change (js/main.js), so this is belt-and-suspenders for a
     browser that matches the media query before that listener runs. */
  html.hero-armed #hero {
    min-height: 0;
    padding-top: var(--hero-pad-top);
    padding-bottom: var(--hero-pad-bottom);
  }

  html.hero-armed #hero .hero-stage {
    position: static;
    min-height: 0;
    padding-top: 0;
  }

  *,
  *::before,
  *::after {
    animation-duration: 0.01ms !important;
    animation-iteration-count: 1 !important;
    transition-duration: 0.01ms !important;
    scroll-behavior: auto !important;
  }
}

/* Print: panels that never scrolled into view were never revealed, so show them all. */
@media print {
  html.js.reveal-armed .reveal {
    opacity: 1;
    transform: none;
    transition: none;
  }
}
