/*
 * Base styles — Direction D+, "warm grey and bohemian".
 *
 * EVERYTHING HERE READS FROM tokens.css. No literal colour, size, or duration appears below, and a test
 * enforces that, because the whole point of the token file is that the visual direction changes in one
 * place.
 *
 * Composition, full-bleed arithmetic, and the zigzag: docs/portfolio/LAYOUT_NOTES.md
 * The palette, colour-by-position, and measured contrast: docs/decisions/D-021
 */

/* Imports first: `@import` must precede every rule, and fonts.css before the rules naming those
   families. Keeps render-blocking requests at two. */
@import url("fonts.b16cd7e2b3a1.css");
@import url("tokens.572cfe65048c.css");

/* --- Reset, minimal on purpose --- */
*,
*::before,
*::after {
  box-sizing: border-box;
}

body,
h1,
h2,
h3,
p,
ul,
ol,
figure,
blockquote {
  margin: 0;
}

ul,
ol {
  padding-inline-start: 1.25em;
}

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

/* --- Page ----------------------------------------------------------------- */
body {
  min-height: 100vh;
  display: flex;
  flex-direction: column;
  gap: var(--space-xl);
  padding-block: var(--space-md);
  /* A very faint warm glow, anchored to the top of the page so it reads as a source of light above the
   * content rather than as a different background colour. It fades into the plain grey ground as you read
   * down, which keeps the lower page neutral for text. See the token for why it is this weak. */
  background-color: var(--color-ground);
  background-image: radial-gradient(
    120% var(--glow-height) at 50% 0%,
    var(--color-glow) 0%,
    var(--color-glow-fade) 70%
  );
  background-repeat: no-repeat;
  color: var(--color-ink);
  font-family: var(--font-body);
  font-size: var(--text-base);
  line-height: var(--leading-body);
  -webkit-text-size-adjust: 100%;
  /* Better glyph rendering on mobile and small text. */
  text-rendering: optimizeLegibility;
}

.wrap {
  width: 100%;
  max-width: var(--measure-wide);
  margin-inline: auto;
  padding-inline: var(--space-md);
}

/* Prose is held narrower than the container: a 68rem line of body text is unreadable. */
.prose {
  max-width: var(--measure);
}

/* The same constraint for one element inside a band, where a .prose wrapper is absent. */
.measure {
  max-width: var(--measure);
}

/* --- Typography ----------------------------------------------------------- */
/* Serif headings, sans body. That contrast is what reads as *editorial* rather than as software,
   and it is the move carrying the academic credibility the author asked for. */
h1,
h2,
h3 {
  font-family: var(--font-display);
  font-weight: 400; /* the serif has presence at display sizes without needing weight */
  line-height: var(--leading-tight);
  letter-spacing: var(--tracking-display);
  text-wrap: balance;
}

h1 {
  font-size: var(--text-display);
  margin-block-end: var(--space-sm);
}

h2 {
  font-size: var(--text-xl);
  margin-block: var(--space-xl) var(--space-sm);
}

h3 {
  font-size: var(--text-lg);
  line-height: var(--leading-snug);
  margin-block: var(--space-md) var(--space-2xs);
}

p + p {
  margin-block-start: var(--space-sm);
}

/* Small mono labels: dates, technologies, statuses, section markers. Reads as precise, not cold. */
.label {
  font-family: var(--font-mono);
  font-size: var(--text-xs);
  letter-spacing: var(--tracking-label);
  text-transform: uppercase;
  color: var(--color-ink-muted);
}

/* A visible rule between sections. Part of the editorial weight: gives the page rhythm. */
.rule {
  border: 0;
  border-block-start: var(--rule-weight) solid var(--color-ink);
  margin-block: var(--space-xl);
}

/* --- Links ---------------------------------------------------------------- */
a {
  color: var(--color-clay);
  text-decoration-thickness: 1px;
  text-underline-offset: 0.18em;
  transition: color var(--duration-fast) var(--ease-settle);
}

a:hover {
  color: var(--color-ink);
  /* The underline colour animates in rather than appearing, which is the cheapest "this was
     designed" detail available and costs nothing. */
  text-decoration-color: currentColor;
}

/* A visible focus ring is an accessibility requirement, never decoration. */
:focus-visible {
  outline: 2px solid var(--color-clay);
  outline-offset: var(--focus-offset);
}

/* Pointer behaviour — note LAYOUT_NOTES.md on hover guards.
 * Every hover state lives inside `@media (hover: hover)`. This is a correctness fix as much as a
 * polish one: without it a touch device can latch a :hover style permanently after one tap. */
@media (hover: hover) {
  .card:hover .card__title {
    text-decoration-color: currentColor;
  }
}

/* --- Numbers --- */
/* Tabular figures, so a date never shifts width as it changes. */
.label,
.card__index {
  font-variant-numeric: tabular-nums;
}

/* --- Cards ---
 * The hover carries the colour: an accent bar grows in from the inline start and the entry shifts.
 * All CSS, gated behind `@media (hover: hover)`. */
.card {
  position: relative;
  display: block;
  padding-block: var(--space-md);
  padding-inline-start: var(--space-sm);
  border-block-start: 1px solid var(--color-rule);
  text-decoration: none;
  color: inherit;
  transition:
    background-color var(--duration-base) var(--ease-settle),
    padding-inline-start var(--duration-base) var(--ease-settle);
}

/* A pseudo-element rather than a border, so it animates its own scale without affecting layout. */
.card::before {
  content: "";
  position: absolute;
  inset-block: 0;
  inset-inline-start: 0;
  inline-size: var(--rule-weight);
  /* The colour comes from the --accent custom property, set by position. */
  transform: scaleY(0);
  transform-origin: top;
  transition: transform var(--duration-base) var(--ease-settle);
}

.card:first-of-type {
  border-block-start: var(--rule-weight) solid var(--color-ink);
}

.card:hover {
  background: var(--color-clay-tint);
  /* The title keeps its own colour on hover, and the entry gets more defined rather than less. An
     earlier version hovered to a tint barely darker than the page, which dissolved the heading's edges
     at the exact moment the visitor was reading it — the author called that "less concrete". */
  color: var(--color-ink);
  padding-inline-start: var(--space-md);
}

.card:hover::before {
  transform: scaleY(1);
  /* The bar thickens as well as appearing, so the response reads as weight rather than only as colour. */
  inline-size: var(--space-3xs);
}

.card__index {
  font-family: var(--font-mono);
  font-size: var(--text-xs);
  letter-spacing: var(--tracking-label);
  color: var(--color-clay);
}

.card__meta {
  display: flex;
  flex-wrap: wrap;
  gap: var(--space-2xs) var(--space-sm);
  align-items: baseline;
  margin-block-end: var(--space-2xs);
}

.card__title {
  font-family: var(--font-display);
  font-size: var(--text-xl);
  line-height: var(--leading-snug);
  letter-spacing: var(--tracking-display);
  text-decoration: underline;
  text-decoration-color: transparent;
  text-decoration-thickness: 2px;
  text-underline-offset: 0.12em;
  transition: text-decoration-color var(--duration-base) var(--ease-settle);
}

.card__summary {
  max-width: var(--measure);
  color: var(--color-ink-muted);
}

/* Technology and link tags. Small, mono, and separated by a rule rather than by a pill — pills are
   the round-ended shape this direction avoids. */
.tags {
  display: flex;
  flex-wrap: wrap;
  gap: var(--space-2xs) var(--space-xs);
  list-style: none;
  padding: 0;
  margin-block-start: var(--space-2xs);
}

/* --- Figures ---
 * `aspect-ratio` reserves space before the image loads, so the page does not jump as images
 * arrive — the Cumulative Layout Shift target in ANIMATION_AND_PERFORMANCE.md. */
.figure {
  margin: 0 0 var(--space-md);
  border: 1px solid var(--color-rule);
  background: var(--color-surface);
}

.figure img {
  display: block;
  width: 100%;
  aspect-ratio: 16 / 10;
  object-fit: cover;
}

.figure figcaption {
  padding: var(--space-2xs) var(--space-xs);
  border-block-start: 1px solid var(--color-rule);
  font-family: var(--font-mono);
  font-size: var(--text-xs);
  color: var(--color-ink-muted);
}

/* --- Section headings with an index ------------------------------------------
 * The numbered marker that makes a page read as a document. Monospace, muted, and set beside the
 * heading rather than above it, so it costs no vertical space.
 */
.detail__meta {
  margin-block-start: var(--space-sm);
}
.section {
  margin-block-start: var(--space-2xl);
}

.section__head {
  display: flex;
  align-items: baseline;
  gap: var(--space-xs);
  padding-block-end: var(--space-2xs);
  border-block-end: var(--rule-weight) solid var(--color-ink);
  margin-block-end: var(--space-md);
}

.section__index {
  font-family: var(--font-mono);
  font-size: var(--text-xs);
  letter-spacing: var(--tracking-label);
  color: var(--color-ink-muted);
  font-variant-numeric: tabular-nums;
}

.section__title {
  font-family: var(--font-display);
  font-size: var(--text-xl);
  line-height: var(--leading-snug);
  letter-spacing: var(--tracking-display);
  margin: 0;
}

/* --- Page header --- */
.page-head {
  margin-block-end: var(--space-lg);
}

.page-head__title {
  font-size: var(--text-display);
  margin-block: var(--space-2xs) 0;
}

.page-head__intro {
  max-width: var(--measure);
  margin-block-start: var(--space-sm);
  font-size: var(--text-lg);
  color: var(--color-ink-muted);
}

/* --- Bohemian accents ---
 * Colour is assigned by POSITION. `--accent` is set once per position and consumed by two rules, so a set of
 * cards reads like printed plates rather than one corporate hue. The colours carry no meaning by design.
 * See LAYOUT_NOTES.md §5 and D-021. */
.section:nth-of-type(6n + 1),
.card:nth-of-type(6n + 1) {
  --accent: var(--color-clay);
}
.section:nth-of-type(6n + 2),
.card:nth-of-type(6n + 2) {
  --accent: var(--color-sage);
}
.section:nth-of-type(6n + 3),
.card:nth-of-type(6n + 3) {
  --accent: var(--color-terracotta);
}
.section:nth-of-type(6n + 4),
.card:nth-of-type(6n + 4) {
  --accent: var(--color-mustard);
}
.section:nth-of-type(6n + 5),
.card:nth-of-type(6n + 5) {
  --accent: var(--color-rose);
}
.section:nth-of-type(6n + 6),
.card:nth-of-type(6n + 6) {
  --accent: var(--color-teal);
}
.card::before {
  background: var(--accent, var(--color-clay));
}
.section .section__index {
  color: var(--accent, var(--color-clay));
}

/* Washes behind alternating sections, for coloured *fields* rather than coloured elements. All six
 * light tones, so a page using four still reads as one palette. */
.section:nth-of-type(even) {
  background: var(--color-sage-wash);
  /* Full-bleed, so the wash is a field rather than a panel with visible edges. */
  margin-inline: calc(50% - 50vw);
  padding-inline: calc(50vw - 50%);
  padding-block: var(--space-lg);
}

.section:nth-of-type(3n) {
  background: var(--color-mustard-wash);
}

/* --- Zigzag sections ---
 * A heading column beside the content, offset so the two do not share a baseline. The eye travels
 * diagonally instead of scanning centred rows. Applied per section on purpose: a page where
 * EVERYTHING is offset has no rhythm left to notice. See LAYOUT_NOTES.md §2. */
@media (min-width: 48rem) {
  .section--zigzag {
    display: grid;
    grid-template-columns: minmax(0, 0.62fr) minmax(0, 1.38fr);
    gap: var(--space-lg);
    align-items: start;
  }

  /* Sticky, so on a long list the title stays beside what is being read. `inset-block-start` clears
   the sticky header. The bottom rule is dropped because a border on a sticky block draws a line
   that moves with the scroll and looks like a rendering fault. */
  .section--zigzag > .section__head {
    position: sticky;
    inset-block-start: var(--space-lg);
    /* The head's own bottom rule would look wrong on a sticky block, so it is dropped here. */
    border-block-end: 0;
    margin-block-end: 0;
    flex-direction: column;
    gap: var(--space-2xs);
  }

  /* Pushed down, so the two columns zigzag rather than align. */
  .section--zigzag > .section__body {
    margin-block-start: var(--space-lg);
  }
}

/* --- Colour blocks ---
 * Full-bleed and deep. Dark and desaturated rather than bright, because a large saturated area on
 * a light grey page reads as a warning banner. See LAYOUT_NOTES.md §1 and §2. */

.band {
  background: var(--color-deep);
  color: var(--color-on-deep);
  /* Full-bleed. Note the arithmetic: the mirrored margin and padding mean content still lands on the
 * container's text edges. See LAYOUT_NOTES.md §1. */
  margin-inline: calc(50% - 50vw);
  padding-inline: calc(50vw - 50%);
  padding-block: var(--space-xl);
  margin-block: var(--space-xl);
}

/* --- The band's composition ---
 * Asymmetric: a narrow column and a wider one offset downward so they do not share a baseline. The offset is
 * what gives the eye a diagonal. See LAYOUT_NOTES.md §2. */
.band__grid {
  display: grid;
  gap: var(--space-md) var(--space-lg);
  /* Wider than `--measure-wide`: the point is to use more of the field the colour has created. */
  max-width: 78rem;
}

@media (min-width: 48rem) {
  .band__grid {
    grid-template-columns: minmax(0, 0.72fr) minmax(0, 1.28fr);
    align-items: start;
  }

  /* The zigzag: the right column sits lower, so the two do not read as one band of text. */
  .band__aside {
    margin-block-start: var(--space-lg);
  }
}

/* Larger than a section heading: it is the only heading on a field of colour, and has the space. */
.band__title {
  font-family: var(--font-display);
  font-size: var(--text-xl);
  line-height: var(--leading-snug);
  letter-spacing: var(--tracking-display);
  margin: 0;
}

/* Decoration for the band's far edge, which is what stops it being empty on a wide screen.
 * aria-hidden in the template; faint, so it reads as texture rather than a second heading.
 * Cubes, borders and text only — masked elements do not render in the verification engine.
 * See LAYOUT_NOTES.md §4. */
.band__mark {
  font-family: var(--font-mono);
  font-size: clamp(1.5rem, 1rem + 2vw, 3rem);
  line-height: 1;
  letter-spacing: 0.2em;
  color: var(--color-on-deep-accent);
  /* Faint: it is a texture for the eye, not a second heading competing for attention. */
  opacity: 0.55;
  margin-block-start: var(--space-md);
}

/* Inside a band, links must not use the accent: it is chosen for the warm ground and would fail on a
   deep background. `--color-on-deep` is the measured counterpart. */
.band a {
  color: var(--color-on-deep);
  text-decoration-color: var(--color-on-deep-muted);
}

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

.band .label,
.band .section__index {
  color: var(--color-on-deep-muted);
}

/* A hairline in the accent at the very top of a band, so the colour has an edge rather than just
   starting. Small detail, and it is what makes a block look placed rather than pasted. */
.band--edged {
  border-block-start: var(--rule-weight) solid var(--color-rule-strong);
}

/* --- The hero -------------------------------------------------------------------
 * Used once, on the home page. The heading is the composition, which is the move the author's
 * references all make and the previous version under-used.
 */
.hero {
  padding-block: var(--space-2xl) var(--space-xl);
}

.hero__title {
  font-size: var(--text-hero);
  margin-block: var(--space-2xs) 0;
  /* A touch tighter than the base display tracking: at 9rem the letters need to sit close. */
  letter-spacing: -0.03em;
}

.hero__lead {
  max-width: var(--measure);
  margin-block-start: var(--space-md);
  font-size: var(--text-lg);
  color: var(--color-ink-muted);
}

/* The accent underline under a phrase in the hero, drawn with a pseudo-element so it can scale in. */
.mark {
  position: relative;
  white-space: nowrap;
}

.mark::after {
  content: "";
  position: absolute;
  inset-inline: 0;
  inset-block-end: 0.04em;
  block-size: 0.16em;
  background: var(--color-rule-strong);
  transform-origin: left;
}

/* --- Ambient motion ---
 * Animation that runs on its own. Rationed to three, each attached to something already meant to be
 * signalling: the live-status dot, the hero underline, and the deep band's edge. All inside
 * `prefers-reduced-motion: no-preference`, so a still interface is fully supported. */

/* Slow on purpose: a fast blink reads as an error state, not as "current". */
@media (prefers-reduced-motion: no-preference) {
  .status--live::before {
    animation: pulse var(--duration-pulse) var(--ease-settle) infinite;
  }
}

@keyframes pulse {
  0%,
  100% {
    opacity: 1;
    transform: scale(1);
  }
  50% {
    opacity: 0.4;
    transform: scale(0.78);
  }
}

/* Draws itself in once, after the heading has arrived, on the phrase that makes the claim.
 * `scaleX` only, so the compositor handles it without repainting the text. */
@media (prefers-reduced-motion: no-preference) {
  .hero__title .mark::after {
    animation: draw var(--duration-slow) var(--ease-settle) both;
    animation-delay: var(--delay-2);
  }
}

@keyframes draw {
  from {
    transform: scaleX(0);
  }
  to {
    transform: scaleX(1);
  }
}

/* The band's leading edge. A boundary that shifts along its length, so the block reads as a lit
 * surface rather than a pasted rectangle. Only `background-position` moves, on a fixed strip. */
@media (prefers-reduced-motion: no-preference) {
  .band--edged {
    background-repeat: no-repeat;
    background-size: 40% 100%;
    background-position: -40% 0;
    animation: sweep calc(var(--duration-pulse) * 4) linear infinite;
  }
}

@keyframes sweep {
  from {
    background-position: -40% 0;
  }
  to {
    background-position: 140% 0;
  }
}

/* A marker for a project still being worked on, or one whose links are withheld. */
.status {
  display: inline-flex;
  align-items: center;
  gap: var(--space-3xs);
  font-family: var(--font-mono);
  font-size: var(--text-xs);
  letter-spacing: var(--tracking-label);
  text-transform: uppercase;
  color: var(--color-clay);
}

.status::before {
  content: "";
  inline-size: 0.5em;
  block-size: 0.5em;
  background: currentColor;
  border-radius: 50%;
}

/* The empty state is a decided behaviour, not an accident — an honest sentence rather than an
   empty list. See PAGE_TREE.md section 6. */
.empty {
  padding-block: var(--space-md);
  border-block-start: 1px solid var(--color-rule);
  color: var(--color-ink-muted);
  max-width: var(--measure);
}

/* --- Sticky header and footer --- */
.site-footer__grid {
  display: grid;
  gap: var(--space-lg);
  padding-block-end: var(--space-md);
}

@media (min-width: 40rem) {
  .site-footer__grid {
    grid-template-columns: 1fr auto;
  }
}

.site-footer__name {
  font-family: var(--font-display);
  font-size: var(--text-lg);
  color: var(--color-ink);
}

.site-footer__tagline {
  color: var(--color-ink-muted);
}

.site-footer__heading {
  font-family: var(--font-mono);
  font-size: var(--text-xs);
  letter-spacing: var(--tracking-label);
  text-transform: uppercase;
  color: var(--color-ink-muted);
  margin-block-end: var(--space-2xs);
}

.site-footer__list {
  list-style: none;
  padding: 0;
}

.site-footer__list li + li {
  margin-block-start: var(--space-3xs);
}

/* --- Placeholder home page --- */

.checks {
  margin-block-start: var(--space-md);
  padding-inline-start: var(--space-sm);
  border-inline-start: var(--rule-weight) solid var(--color-rule);
  color: var(--color-ink-muted);
}

.checks li + li {
  margin-block-start: var(--space-3xs);
}

/* Persian spans carry lang="fa" dir="rtl", so the browser picks the face and direction for that
   span alone. The page stays LTR. */
[lang="fa"],
[dir="rtl"] {
  font-family: var(--font-persian);
}

/* --- Skip link ------------------------------------------------------------ */
.skip-link {
  position: absolute;
  inset-block-start: var(--space-xs);
  inset-inline-start: var(--space-xs);
  z-index: 10;
  padding: var(--space-2xs) var(--space-xs);
  background: var(--color-surface);
  border: 1px solid var(--color-rule);
  border-radius: var(--radius);
  transform: translateY(-200%);
  transition: transform var(--duration-fast) var(--ease-settle);
}

.skip-link:focus {
  transform: translateY(0);
}

/* Sticky, so the contact route stays reachable from anywhere on a long page — the 60-second rule
 * in SPEC.md section 1 depends on it. The translucent ground plus a blur keeps content legible as
 * it scrolls underneath. */
.site-header {
  position: sticky;
  inset-block-start: 0;
  z-index: 5;
  background: color-mix(in srgb, var(--color-ground) 88%, transparent);
  backdrop-filter: blur(var(--blur-header));
  border-block-end: 1px solid var(--color-rule);
  padding-block-end: var(--space-2xs);
  transition: transform var(--duration-base) var(--ease-settle);
}

/* Slid out of the way, not removed: `display: none` would break the sticky context and make the header
   reappear with a jump. A transform is composited and leaves the layout stable. */
.site-header--hidden {
  transform: translateY(-100%);
}

.site-header__inner {
  display: flex;
  flex-wrap: wrap;
  align-items: baseline;
  justify-content: space-between;
  gap: var(--space-2xs) var(--space-sm);
}

/* On a narrow screen the brand and the nav each take the full width, so the two rows stay predictable
   instead of reflowing as the nav wraps. */
@media (max-width: 34rem) {
  .site-header__inner {
    flex-direction: column;
    align-items: stretch;
    gap: var(--space-3xs);
  }
}

.site-header__brand {
  display: flex;
  flex-direction: column;
  text-decoration: none;
  color: inherit;
}

.site-header__name {
  font-family: var(--font-display);
  font-size: var(--text-lg);
  line-height: var(--leading-snug);
}

.site-header__tagline {
  font-size: var(--text-sm);
  color: var(--color-ink-muted);
}

/* On a phone the tagline is dropped, removing a whole line from a sticky header. It is still on the About
   page and in the footer, so nothing is lost. */
@media (max-width: 34rem) {
  .site-header__tagline {
    display: none;
  }
}

/* TWO LINES ON A PHONE, never wider than the screen.
 *
 * Measured: seven labels need ~476px on one row and a 360px phone offers ~328px, so two lines is the honest
 * layout.
 *
 * `min-width: 0` is the load-bearing declaration — without it a flex child refuses to shrink below its
 * content, so the nav pushed the WHOLE PAGE wide and the reader had to scroll the document. That was the bug.
 * See LAYOUT_NOTES.md §6. */
.site-nav {
  min-width: 0;
  max-width: 100%;
}

.site-nav__list {
  display: grid;
  /* Four columns: two fixed rows, and it always fits. */
  grid-template-columns: repeat(4, minmax(0, 1fr));
  gap: var(--space-2xs) var(--space-sm);
  list-style: none;
  padding: 0;
  margin: 0;
  font-family: var(--font-mono);
  font-size: var(--text-xs);
  letter-spacing: var(--tracking-label);
  text-transform: uppercase;
}

@media (min-width: 34rem) {
  /* On a wider screen the items share a single row, aligned to the end so they sit under the brand. */
  .site-nav__list {
    display: flex;
    flex-wrap: wrap;
    justify-content: flex-end;
  }
}

.site-nav a {
  text-decoration: none;
  color: var(--color-ink-muted);
}

.site-nav a:hover {
  color: var(--color-clay);
  text-decoration: underline;
}

/* The current page is marked for assistive technology by `aria-current` and visually here. Never
   colour alone: the underline carries the same information. */
.site-nav a[aria-current="page"] {
  color: var(--color-ink);
  text-decoration: underline;
}

/* --- Main ----------------------------------------------------------------- */
main {
  flex: 1;
}

/* --- Footer --------------------------------------------------------------- */
.site-footer {
  border-block-start: 1px solid var(--color-rule);
  padding-block-start: var(--space-sm);
  color: var(--color-ink-muted);
  font-size: var(--text-sm);
}

.site-footer__meta {
  margin-block-start: var(--space-3xs);
  font-family: var(--font-mono);
  font-size: var(--text-xs);
}

/* --- Motion ---
 * Three layers, cheapest first, all CSS.
 * 1. Entrance  — the hero plays on load. Pure CSS, so it works with JavaScript disabled.
 * 2. Reveal    — elements rise as they enter the viewport.
 * 3. (removed) — see the note on the reveal rule below.
 */

/* 1. Entrance. `fill-mode: both` starts the element in the `from` state, so there is no flash of
   the final position first. */
@media (prefers-reduced-motion: no-preference) {
  .hero__title,
  .page-head__title {
    animation: rise var(--duration-slow) var(--ease-fluid) both;
  }

  .hero__lead,
  .page-head__intro {
    animation: rise var(--duration-slow) var(--ease-fluid) both;
    animation-delay: var(--delay-2);
  }

  .hero .label,
  .page-head .label {
    animation: rise var(--duration-slow) var(--ease-fluid) both;
    animation-delay: var(--delay-1);
  }
}

@keyframes rise {
  from {
    opacity: 0;
    transform: translateY(1.25rem);
  }
  to {
    opacity: 1;
    transform: none;
  }
}

/* 2. Scroll reveal.
 *
 * CONTENT IS VISIBLE BY DEFAULT. Two guards: the stylesheet does nothing unless <html> carries `.js`, which
 * the script adds only after confirming IntersectionObserver exists, and even then it animates only elements
 * the script has marked. The failure mode is "no animation", never "no content".
 *
 * A scroll-driven `animation-timeline: view()` version was written and REMOVED: with `fill-mode: both`, an
 * element scrolled past its range is held at `opacity: 0`, leaving content permanently invisible. A test
 * asserts none is present. */
@media (prefers-reduced-motion: no-preference) {
  .js [data-reveal].is-revealed {
    animation: reveal var(--duration-slow) var(--ease-fluid) both;
  }

  /* The cascade: consecutive items reveal in sequence, which is what makes a list feel like it is
 * arriving. Delays are capped at the sixth item — without a cap, twenty entries would leave the
 * last row waiting over a second and the cascade would read as lag rather than rhythm. */
  .js .section :nth-child(2) [data-reveal],
  .js .section [data-reveal]:nth-child(2) {
    animation-delay: calc(var(--stagger) * 1);
  }

  .js .section [data-reveal]:nth-child(3) {
    animation-delay: calc(var(--stagger) * 2);
  }

  .js .section [data-reveal]:nth-child(4) {
    animation-delay: calc(var(--stagger) * 3);
  }

  .js .section [data-reveal]:nth-child(n + 5) {
    animation-delay: calc(var(--stagger) * 4);
  }
}

@keyframes reveal {
  from {
    opacity: 0;
    transform: translateY(1.5rem) scale(0.985);
  }
  to {
    opacity: 1;
    transform: none;
  }
}

/* --- Entries -------------------------------------------------------------------
 * One row of a list: a publication, a model card, a reading-log entry, a service package. A single shared
 * class rather than four near-identical ones, so a change to list rhythm happens in one place.
 */
.entry {
  padding-block: var(--space-md);
  border-block-start: 1px solid var(--color-rule);
}

.entry:first-of-type {
  border-block-start: var(--rule-weight) solid var(--color-ink);
}

.entry__meta {
  display: flex;
  flex-wrap: wrap;
  gap: var(--space-2xs) var(--space-sm);
  align-items: baseline;
  margin-block-end: var(--space-2xs);
}

.entry__title {
  font-family: var(--font-display);
  font-size: var(--text-lg);
  line-height: var(--leading-snug);
  letter-spacing: var(--tracking-display);
  margin-block: 0 var(--space-2xs);
}

.entry__byline {
  font-size: var(--text-sm);
  color: var(--color-ink-muted);
  margin-block-end: var(--space-2xs);
}

.entry__body {
  max-width: var(--measure);
  color: var(--color-ink-muted);
}

.entry__detail {
  max-width: var(--measure);
  margin-block-start: var(--space-2xs);
  font-size: var(--text-sm);
}

/* A withheld result: italic and faint, so the absence reads as a decision rather than an omission. */
.entry__detail--withheld {
  color: var(--color-ink-faint);
  font-style: italic;
}

.entry__ids {
  display: flex;
  flex-wrap: wrap;
  gap: var(--space-2xs) var(--space-sm);
  margin-block-start: var(--space-2xs);
}

/* --- Status badge ---
 * The word carries the information and the colour is only a hint, so every variant has distinct wording as
 * well as a distinct colour. Two groups, not a spectrum: "exists in the world" and "still in progress". */
.badge {
  display: inline-block;
  padding: 0.1em 0.5em;
  font-family: var(--font-mono);
  font-size: var(--text-xs);
  letter-spacing: var(--tracking-label);
  text-transform: uppercase;
  border: 1px solid currentColor;
}

.badge[data-status="published"],
.badge[data-status="deployed"],
.badge[data-status="accepted"],
.badge[data-status="trained-model"] {
  color: var(--color-sage);
}

.badge[data-status="under-review"],
.badge[data-status="preprint"],
.badge[data-status="in-preparation"],
.badge[data-status="unpublished"],
.badge[data-status="experiment"],
.badge[data-status="idea"],
.badge[data-status="archived"] {
  color: var(--color-terracotta);
}

/* --- Reading log ---
 * The takeaway is the point of an entry, so it is set as a quotation rather than as body text.
 */
.takeaway {
  margin-block: var(--space-2xs);
  padding-inline-start: var(--space-sm);
  border-inline-start: var(--rule-weight) solid var(--color-sage);
  max-width: var(--measure);
  font-size: var(--text-lg);
}

/* --- Services ---
 * Packages side by side where there is room, so a visitor can compare them rather than scroll between
 * them. One column on narrow screens, because a comparison table on a phone is just a list.
 */
.packages {
  display: grid;
  gap: var(--space-lg);
}

@media (min-width: 52rem) {
  .packages {
    grid-template-columns: repeat(auto-fit, minmax(18rem, 1fr));
  }

  /* Side by side, the packages are not a stacked list, so the first one's heavier top rule is dropped. */
  .packages .entry:first-of-type {
    border-block-start: 1px solid var(--color-rule);
  }
}

.features {
  margin-block-start: var(--space-2xs);
  padding-inline-start: 0;
  list-style: none;
  max-width: var(--measure);
  font-size: var(--text-sm);
}

.features li {
  position: relative;
  padding-inline-start: var(--space-sm);
}

.features li + li {
  margin-block-start: var(--space-3xs);
}

/* The bullet is a pseudo-element rather than a list marker, so it can take the accent colour. */
.features li::before {
  content: "";
  position: absolute;
  inset-inline-start: 0;
  inset-block-start: 0.6em;
  inline-size: var(--space-2xs);
  block-size: var(--rule-weight);
  background: var(--color-clay);
}

.package__price {
  margin-block-start: var(--space-sm);
  font-family: var(--font-mono);
  font-size: var(--text-sm);
}

/* --- Contact ---
 * The address is the largest thing on the page on purpose. This page exists to hand over one piece of
 * information, and SPEC.md section 1 makes finding it the measure of the whole site.
 */
.contact__email {
  font-family: var(--font-display);
  font-size: var(--text-xl);
  line-height: var(--leading-snug);
  letter-spacing: var(--tracking-display);
  word-break: break-word;
}

/* --- Shared list styling ------------------------------------------------------ */

/* A list with a rule down its inline start, used for link lists on About, Contact and a project. */
.checks {
  margin-block-start: var(--space-md);
  padding-inline-start: var(--space-sm);
  border-inline-start: var(--rule-weight) solid var(--color-rule);
  color: var(--color-ink-muted);
}

.checks li + li {
  margin-block-start: var(--space-3xs);
}

/* --- Skills ---
 * Grouped on the About page. The note beside a skill is a concrete qualifier rather than a rating, so it is
 * set apart from the name to read as evidence. */
.skill-group + .skill-group {
  margin-block-start: var(--space-md);
}

.skill-group .features {
  margin-block-start: var(--space-3xs);
  font-size: var(--text-base);
}

.skill-note {
  color: var(--color-ink-muted);
  font-size: var(--text-sm);
}

/* The page never scrolls sideways. `clip` not `hidden`, because `hidden` on the body also disables the
 * sticky header in some browsers. */
body {
  overflow-x: clip;
}
