/* ===========================================================================
   WebDeviAnt — components
   The instrument: header rail, baseline plane, readouts, cards, timeline,
   form, footer.
   ========================================================================= */

/* ------------------------------------------------------- the baseline ----
   His synthwave floor, kept verbatim in look and rebuilt to run on the
   compositor. The original animated `background-position`, which repaints
   the whole masked, perspective-transformed layer every frame; this
   translates a tile strip instead, so it is a transform-only animation.

   Conceptually it stops being decoration: it is the accepted standard the
   tagline is measured against.                                            */

.baseline {
  position: fixed;
  inset: auto 0 0 0;
  /* Puts the horizon at ~70% of the viewport — below the headline rather
     than slicing through it, which is where the old absolute-positioned
     version ended up. */
  height: 30vh;
  overflow: hidden;
  pointer-events: none;
  z-index: 0;
  opacity: var(--floor-intensity);
  transition: opacity calc(var(--t-reveal) * 2) var(--ease-inout);
  contain: strict;
}

.baseline__plane {
  position: absolute;
  inset: 0;
  transform: perspective(160px) rotateX(50deg) scaleX(2);
  transform-origin: top center;
  -webkit-mask-image: linear-gradient(to bottom, transparent 0%, #000 60%);
          mask-image: linear-gradient(to bottom, transparent 0%, #000 60%);
}

/* 200% tall so a one-tile translate loops seamlessly against the 20px tile.
 *
 * SPEED: the original animated `background-position: 0 100% -> 0`, which on a
 * ~360px-tall plane is a ~340px journey in 16s — about 21px/s, passing through
 * the 20px tile roughly seventeen times. Translating a single tile over that
 * same 16s would therefore be seventeen times too slow and read as frozen, so
 * the duration below is per-tile, not per-cycle.                            */
.baseline__tiles {
  position: absolute;
  left: 0;
  right: 0;
  top: -100%;
  height: 200%;
  background-image:
    linear-gradient(90deg,  var(--live) 1px, transparent 0),
    linear-gradient(180deg, var(--past) 1px, transparent 0);
  background-size: 40px 20px;
  animation: baseline-scroll var(--baseline-tile-dur) linear infinite;
  will-change: transform;
}

@keyframes baseline-scroll {
  from { transform: translate3d(0, 0, 0); }
  to   { transform: translate3d(0, 20px, 0); }
}

/* The horizon: a soft bloom where the plane meets the mean. */
.baseline__horizon {
  position: absolute;
  left: 0;
  right: 0;
  top: 0;
  height: 1px;
  background: var(--live);
  box-shadow:
    0 0 18px 2px color-mix(in srgb, var(--live) 55%, transparent),
    0 0 60px 8px color-mix(in srgb, var(--atmos) 60%, transparent);
}

/* Quiet everywhere, loud exactly twice — the hero and the footer.
 *
 * Declared on `body`, not `:root`, because `.is-lit` is toggled on the body.
 * Anything wanting to override this must therefore also target `body` — a
 * `:root` declaration is shadowed by the one here and never reaches
 * `.baseline`. See the matching overrides in adapt.css. */
body { --floor-intensity: 0.16; }
body.is-lit { --floor-intensity: 0.5; }

/* ------------------------------------------------------------- the mark --
   The real 1.7KB ant vector, painted through a CSS mask so it inherits
   currentColor without being inlined into every page. Replaces the old
   13.5KB SVG-wrapping-a-base64-PNG, which could only ever be one colour.  */

.mark {
  display: block;
  aspect-ratio: 1;
  background-color: currentColor;
  -webkit-mask: url('/assets/img/mark.svg') center / contain no-repeat;
          mask: url('/assets/img/mark.svg') center / contain no-repeat;
}

/* ----------------------------------------------------------------- icons --
   Same technique as the mark: an external SVG used as a mask, so the glyph
   inherits currentColor and every page shares one cached file rather than
   carrying its own inline copy of each path.                               */

.icon {
  display: inline-block;
  inline-size: 1.15rem;
  block-size: 1.15rem;
  flex: none;
  vertical-align: -0.22em;
  background-color: currentColor;
  -webkit-mask-position: center;
          mask-position: center;
  -webkit-mask-size: contain;
          mask-size: contain;
  -webkit-mask-repeat: no-repeat;
          mask-repeat: no-repeat;
}

.icon--linkedin {
  -webkit-mask-image: url('/assets/img/linkedin.svg');
          mask-image: url('/assets/img/linkedin.svg');
}
.icon--github {
  -webkit-mask-image: url('/assets/img/github.svg');
          mask-image: url('/assets/img/github.svg');
}

/* ----------------------------------------------------------- header rail --
   Identical on every page. The site is one document set — home, CV and
   Studios — and this is the component that makes it read that way.        */

.rail {
  position: sticky;
  top: 0;
  z-index: var(--z-nav);
  background: color-mix(in srgb, var(--bg) 82%, transparent);
  backdrop-filter: blur(12px);
  border-bottom: 1px solid var(--rule);
}

.rail__inner {
  width: var(--shell);
  margin-inline: auto;
  display: flex;
  align-items: center;
  gap: var(--m-3);
  height: var(--m-8);
}

.rail__brand {
  display: inline-flex;
  align-items: center;
  gap: 0.7ch;
  text-decoration: none;
  font-weight: 700;
  letter-spacing: -0.02em;
  white-space: nowrap;
}
.rail__brand .mark { width: 1.5rem; height: 1.5rem; flex: none; }
.rail__brand .ant { color: var(--live); }

.rail__nav {
  margin-left: auto;
  display: flex;
  align-items: center;
  gap: var(--m-2);
}

.rail__link {
  font-size: var(--t-micro);
  text-transform: uppercase;
  letter-spacing: 0.18em;
  font-weight: 700;
  color: var(--fg-dim);
  text-decoration: none;
  padding: 0.5rem 0;
  position: relative;
  transition: color var(--t-micro-dur) var(--ease-out);
}
.rail__link::after {
  content: '';
  position: absolute;
  left: 0; right: 0; bottom: 0;
  height: 1px;
  background: var(--live);
  transform: scaleX(0);
  transform-origin: left;
  transition: transform var(--t-micro-dur) var(--ease-out);
}
/* Matches both aria-current="page" (hand-authored, current document) and
   aria-current="true" (written by the scroll spy for the current section).
   Targeting only ='page' meant the spy indicator never rendered. */
.rail__link:hover,
.rail__link[aria-current]:not([aria-current='false']) { color: var(--fg); }
.rail__link:hover::after,
.rail__link[aria-current]:not([aria-current='false'])::after { transform: scaleX(1); }

@media (max-width: 40rem) {
  .rail__nav { gap: var(--m-1); }
  .rail__link { letter-spacing: 0.1em; }
  .rail__link--optional { display: none; }
}

/* Below ~360px the wordmark and four links no longer fit. Drop the wordmark
   to leave the mark alone — but visually, not from the accessibility tree,
   so the home link keeps its name. */
@media (max-width: 23rem) {
  .rail__inner { gap: var(--m-1); }
  .rail__link { letter-spacing: 0.05em; }
  .rail__brand > span:last-child {
    position: absolute;
    width: 1px;
    height: 1px;
    overflow: hidden;
    clip-path: inset(50%);
    white-space: nowrap;
  }
}

/* ------------------------------------------------------------------ hero -- */

.hero {
  position: relative;
  z-index: 1;
  padding-block: var(--m-8) var(--m-12);
  min-height: min(82svh, 50rem);
  display: flex;
  flex-direction: column;
  justify-content: center;
}

/* A scrim between the plane and the words. The headline is allowed to sit on
   the horizon — that is the composition — but prose is never asked to compete
   with a moving grid behind it. Sits above the baseline (z 0) and below the
   hero's own content, inside the hero's stacking context. */
.hero::before {
  content: '';
  position: absolute;
  /* Bleeds just past the shell gutter, never past the viewport. An earlier
     -50vw bleed made the document wider than the screen and produced a
     horizontal scrollbar on mobile. */
  inset: -20% -2.5rem -10%;
  z-index: -1;
  pointer-events: none;
  background: radial-gradient(120% 68% at 22% 62%, var(--bg) 46%, transparent 82%);
}

/* The logo's 720° spin — his signature, kept exactly, retimed as the site's
   one loud beat. It is the only element permitted this much motion. */
.hero__mark {
  width: clamp(4.5rem, 9vw, 7rem);
  height: auto;
  margin-bottom: var(--m-4);
  color: var(--live);
  animation: overture var(--t-overture) var(--ease-out) both;
}

@keyframes overture {
  from { rotate: 0turn;   opacity: 0; scale: 0.7; }
  to   { rotate: 2turn;   opacity: 1; scale: 1; }
}

/* WebDevi·Ant — the split wordmark. The concept delivered in one second:
   the "Ant" inside the word steps off the line by exactly one sigma. */
.wordmark { display: block; }
.wordmark .ant {
  color: var(--live);
  display: inline-block;
  translate: 0 calc(var(--sigma) * -0.34);
  text-shadow: 0 0 28px color-mix(in srgb, var(--live) 42%, transparent);
}
.wordmark .tld { color: var(--fg-faint); }

.hero__lead { max-width: 54ch; margin-top: var(--m-3); }
.hero__actions {
  display: flex;
  flex-wrap: wrap;
  gap: var(--m-2);
  margin-top: var(--m-4);
}

/* ---------------------------------------------------------------- buttons -- */

.btn {
  --btn-fg: var(--fg);
  --btn-bd: var(--rule-strong);
  display: inline-flex;
  align-items: center;
  gap: 0.75ch;
  padding: 0.75rem 1.25rem;
  border: 1px solid var(--btn-bd);
  background: transparent;
  color: var(--btn-fg);
  font-size: var(--t-small);
  font-weight: 700;
  letter-spacing: 0.08em;
  text-transform: uppercase;
  text-decoration: none;
  cursor: pointer;
  border-radius: 2px;
  transition:
    color var(--t-micro-dur) var(--ease-out),
    background-color var(--t-micro-dur) var(--ease-out),
    border-color var(--t-micro-dur) var(--ease-out),
    transform var(--t-micro-dur) var(--ease-out);
}
.btn:hover { transform: translateY(-1px); }
.btn:active { transform: translateY(0); }

.btn--primary {
  --btn-bd: var(--live);
  --btn-fg: var(--live);
}
.btn--primary:hover {
  background: var(--live);
  color: var(--live-ink);
  box-shadow: 0 0 24px color-mix(in srgb, var(--live) 35%, transparent);
}

.btn--ghost:hover { --btn-bd: var(--fg-dim); }

.btn[disabled], .btn[aria-disabled='true'] {
  opacity: 0.5;
  cursor: not-allowed;
  transform: none;
}

/* ------------------------------------------------------------- proof row -- */

.stats {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(min(100%, 11rem), 1fr));
  gap: var(--m-3);
  border-top: 1px solid var(--rule);
  padding-top: var(--m-3);
}
.stat__value {
  display: block;
  font-size: clamp(2rem, 4.5vw, 3rem);
  font-weight: 700;
  line-height: 1;
  letter-spacing: -0.04em;
  font-variant-numeric: tabular-nums;
}
.stat__label {
  display: block;
  margin-top: 0.5rem;
  color: var(--fg-dim);
  font-size: var(--t-small);
  line-height: 1.4;
}

/* ------------------------------------------------------------------ cards --
   Type on rules, not boxes. Restraint is the point.                        */

.card {
  border-top: 1px solid var(--rule);
  padding-top: var(--m-2);
  position: relative;
}
.card__index { color: var(--live); }
.card__title { margin-top: var(--m-1); }
.card__text { color: var(--fg-dim); margin-top: var(--m-1); font-size: var(--t-small); line-height: 1.65; }
.card__list { margin-top: var(--m-2); display: flex; flex-wrap: wrap; gap: 0.4rem; }

.tag {
  font-size: var(--t-micro);
  letter-spacing: 0.08em;
  text-transform: uppercase;
  color: var(--fg-faint);
  border: 1px solid var(--rule);
  padding: 0.2rem 0.5rem;
  border-radius: 2px;
  white-space: nowrap;
}
.tag--live { color: var(--live); border-color: color-mix(in srgb, var(--live) 40%, transparent); }

/* --------------------------------------------------------------- timeline --
   A straight rail, reverse-chronological. The eight founder years step into
   their own lane and rejoin — "he ran his own company while also doing that"
   is a picture anyone reads instantly, with no metaphor to decode.         */

.timeline { position: relative; padding-left: var(--m-3); }
.timeline::before {
  content: '';
  position: absolute;
  left: 0; top: 0.6rem; bottom: 0.6rem;
  width: 1px;
  background: linear-gradient(to bottom, var(--live), var(--past) 40%, var(--rule));
}

.role { position: relative; padding-bottom: var(--m-6); }
.role:last-child { padding-bottom: 0; }

.role::before {
  content: '';
  position: absolute;
  left: calc(var(--m-3) * -1);
  top: 0.65rem;
  width: 7px; height: 7px;
  translate: -3px 0;
  border-radius: 50%;
  background: var(--past);
  box-shadow: 0 0 0 4px var(--bg);
}
.role--current::before {
  background: var(--live);
  box-shadow: 0 0 0 4px var(--bg), 0 0 14px color-mix(in srgb, var(--live) 70%, transparent);
}

.role__date {
  color: var(--past-text);
  font-size: var(--t-micro);
  letter-spacing: 0.14em;
  text-transform: uppercase;
  font-weight: 700;
  font-variant-numeric: tabular-nums;
}
.role--current .role__date { color: var(--live); }

.role__title { margin-top: 0.35rem; font-size: var(--t-h3); }
.role__org { color: var(--fg-dim); font-size: var(--t-small); }
.role__text { color: var(--fg-dim); margin-top: var(--m-1); font-size: var(--t-small); max-width: 62ch; }
.role__stack { margin-top: var(--m-1); color: var(--fg-faint); font-size: var(--t-small); }

/* The deviation: the founder years leave the axis. */
.role--deviant {
  margin-left: var(--sigma);
  border-left: 1px dashed color-mix(in srgb, var(--live) 45%, transparent);
  padding-left: var(--m-2);
  margin-block: var(--m-2);
}
.role--deviant::before { left: calc((var(--sigma) + var(--m-3)) * -1); }
.role--deviant .role__title { color: var(--live); }

/* ----------------------------------------------------------- capabilities --
   Replaces the percentage bars. Self-assessed percentages invite the reader
   to discount you and read as a template; evidence does not.               */

.cap { border-top: 1px solid var(--rule); padding-top: var(--m-2); }
.cap__name { font-size: var(--t-h3); }
.cap__level {
  font-size: var(--t-micro);
  text-transform: uppercase;
  letter-spacing: 0.14em;
  font-weight: 700;
  color: var(--live);
}
.cap__evidence { color: var(--fg-dim); font-size: var(--t-small); margin-top: var(--m-1); }

/* -------------------------------------------------------------- toolchain -- */

.tools {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(min(100%, 9rem), 1fr));
  gap: 1px;
  background: var(--rule);
  border: 1px solid var(--rule);
}
.tool {
  background: var(--bg);
  padding: var(--m-2);
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 0.6rem;
  text-decoration: none;
  transition: background-color var(--t-micro-dur) var(--ease-out);
}
.tool:hover { background: var(--bg-raised); }
.tool img {
  height: 2rem; width: auto;
  filter: grayscale(1) brightness(1.7) contrast(0.85);
  opacity: 0.75;
  transition: filter var(--t-micro-dur) var(--ease-out), opacity var(--t-micro-dur) var(--ease-out);
}
.tool:hover img { filter: none; opacity: 1; }
.tool span { font-size: var(--t-micro); letter-spacing: 0.1em; text-transform: uppercase; color: var(--fg-dim); }

/* ------------------------------------------------------------------ forms -- */

.field { display: block; margin-bottom: var(--m-2); }
.field__label {
  display: block;
  font-size: var(--t-micro);
  text-transform: uppercase;
  letter-spacing: 0.16em;
  font-weight: 700;
  color: var(--fg-dim);
  margin-bottom: 0.5rem;
}
.field__req { color: var(--live); }

.input, .textarea, .select {
  width: 100%;
  background: var(--bg-sunken);
  border: 1px solid var(--rule-strong);
  border-radius: 2px;
  padding: 0.7rem 0.85rem;
  color: var(--fg);
  font-size: var(--t-body);
  transition: border-color var(--t-micro-dur) var(--ease-out),
              box-shadow var(--t-micro-dur) var(--ease-out);
}
.textarea { resize: vertical; min-height: 8rem; line-height: 1.6; }
.select {
  appearance: none;
  background-image: linear-gradient(45deg, transparent 50%, var(--fg-dim) 50%),
                    linear-gradient(135deg, var(--fg-dim) 50%, transparent 50%);
  background-position: calc(100% - 1.1rem) 1.15rem, calc(100% - 0.75rem) 1.15rem;
  background-size: 6px 6px;
  background-repeat: no-repeat;
  padding-right: 2.5rem;
}
.select option { background: var(--bg-raised); color: var(--fg); }

.input:focus, .textarea:focus, .select:focus {
  outline: none;
  border-color: var(--live);
  box-shadow: 0 0 0 3px color-mix(in srgb, var(--live) 18%, transparent);
}

.input[aria-invalid='true'], .textarea[aria-invalid='true'], .select[aria-invalid='true'] {
  border-color: #ff6b6b;
}
.field__error {
  display: block;
  margin-top: 0.4rem;
  color: #ff8f8f;
  font-size: var(--t-small);
}
.field__error:empty { display: none; }

/* Bot bait. Off-screen rather than display:none — some crawlers skip
   hidden inputs, and this one wants to be found and filled in. */
.honeypot {
  position: absolute;
  left: -9999px;
  width: 1px; height: 1px;
  opacity: 0;
  pointer-events: none;
}

.form__status {
  margin-top: var(--m-2);
  padding: var(--m-2);
  border: 1px solid var(--rule-strong);
  border-left-width: 3px;
  font-size: var(--t-small);
}
.form__status:empty { display: none; }
.form__status[data-state='ok']    { border-left-color: var(--live); color: var(--fg); }
.form__status[data-state='error'] { border-left-color: #ff6b6b; color: #ffc9c9; }
.form__status[data-state='busy']  { border-left-color: var(--past); color: var(--fg-dim); }

.form__note { color: var(--fg-faint); font-size: var(--t-small); margin-top: var(--m-2); }

/* ----------------------------------------------------------- outlier plot --
   The concept's punchline, used exactly once. Decorative: every number it
   implies is also stated in prose beside it.                              */

.plot { width: 100%; height: auto; overflow: visible; }
.plot__curve {
  fill: none;
  stroke: var(--past);
  stroke-width: 1.5;
  stroke-linecap: round;
  opacity: 0.85;
}
.plot__axis { stroke: var(--rule-strong); stroke-width: 1; }
.plot__point { fill: var(--live); }
.plot__label {
  font-size: 11px;
  letter-spacing: 0.14em;
  text-transform: uppercase;
  fill: var(--fg-faint);
  font-weight: 700;
}
.plot__label--live { fill: var(--live); }

/* ---------------------------------------------------------------- contact -- */

.contact-links { display: grid; gap: var(--m-2); }
.contact-link {
  display: flex;
  align-items: baseline;
  gap: var(--m-2);
  text-decoration: none;
  border-top: 1px solid var(--rule);
  padding-top: var(--m-1);
  transition: border-color var(--t-micro-dur) var(--ease-out);
}
.contact-link:hover { border-color: var(--live); }
.contact-link__value { color: var(--fg); word-break: break-word; text-decoration: none; }
.contact-link:hover .contact-link__value { color: var(--live); }
a.contact-link__value:hover,
a.contact-link__value:focus-visible { color: var(--live); text-decoration: underline; }

.copy-btn {
  background: transparent;
  border: 1px solid var(--rule-strong);
  color: var(--fg-faint);
  font-size: var(--t-micro);
  letter-spacing: 0.12em;
  text-transform: uppercase;
  padding: 0.25rem 0.5rem;
  border-radius: 2px;
  cursor: pointer;
  margin-left: auto;
  flex: none;
  transition: color var(--t-micro-dur) var(--ease-out),
              border-color var(--t-micro-dur) var(--ease-out);
}
.copy-btn:hover { color: var(--live); border-color: var(--live); }

/* ----------------------------------------------------------------- footer -- */

.footer {
  position: relative;
  z-index: 1;
  border-top: 1px solid var(--rule);
  padding-block: var(--m-6) var(--m-8);
  margin-top: var(--m-12);
}

/* The plane is at its brightest here — this is the second of its two loud
   moments — so the words need a floor of their own. Without this the colophon
   is read straight through the moving grid. Full-bleed, and inside the
   footer's own stacking context so it sits above the plane but below the text. */
.footer::before {
  content: '';
  position: absolute;
  inset: 0;
  z-index: -1;
  pointer-events: none;
  background: linear-gradient(
    to bottom,
    color-mix(in srgb, var(--bg) 94%, transparent) 0%,
    color-mix(in srgb, var(--bg) 82%, transparent) 60%,
    color-mix(in srgb, var(--bg) 40%, transparent) 100%
  );
}

/* Two columns that actually line up: both start at the same top edge, and the
   second is right-aligned as a block rather than being pushed around by
   whatever the first column's last line happens to be. */
.footer__grid {
  display: grid;
  grid-template-columns: minmax(0, 1fr) auto;
  gap: var(--m-4) var(--m-6);
  align-items: start;
}

.footer__end {
  display: grid;
  gap: var(--m-2);
  justify-items: end;
  text-align: right;
}

@media (max-width: 46rem) {
  .footer__grid { grid-template-columns: minmax(0, 1fr); }
  .footer__end { justify-items: start; text-align: left; }
}

.footer__links {
  display: flex;
  flex-wrap: wrap;
  gap: 0.4rem var(--m-2);
}
.footer__links a {
  color: var(--fg-dim);
  text-decoration: none;
  transition: color var(--t-micro-dur) var(--ease-out);
}
.footer__links a:hover { color: var(--live); }

.footer__socials { display: flex; gap: var(--m-2); align-items: center; }
.footer__socials a {
  color: var(--fg-dim);
  display: grid;
  place-items: center;
  padding: 0.45rem;
  border: 1px solid var(--rule);
  border-radius: 3px;
  transition: color var(--t-micro-dur) var(--ease-out),
              border-color var(--t-micro-dur) var(--ease-out);
}
.footer__socials a:hover { color: var(--live); border-color: var(--live); }
.footer__socials .icon { vertical-align: 0; }

/* Icon + the readable handle. The icon is the quick visual hook; the text
   stays because a printed CV cannot show a mask-image, and a recruiter
   copying a profile URL by hand needs to be able to read it. */
.profiles { display: grid; gap: 0.35rem; }
.profiles a {
  display: inline-flex;
  align-items: center;
  gap: 0.6ch;
  color: var(--fg);
  text-decoration: none;
  width: fit-content;
  transition: color var(--t-micro-dur) var(--ease-out);
}
.profiles a:hover,
.profiles a:focus-visible { color: var(--live); text-decoration: underline; }

/* The colophon is the portfolio: for a developer selling development, a page
   that provably ships no framework is the strongest evidence on the site. */
.colophon {
  color: var(--fg-dim);
  font-size: var(--t-small);
  max-width: 46ch;
  line-height: 1.65;
}
.colophon b { color: var(--fg); font-weight: 700; }

/* --------------------------------------------------------------- reveals --
   Content is visible by default. It is hidden ONLY once the script has
   confirmed it can and will reveal it again — hence the html[data-animate]
   gate, which JS sets after checking for IntersectionObserver and for a
   motion preference.

   Written this way round deliberately. The obvious approach (opacity:0 in
   CSS, removed by JS) means any failure between paint and observer — a
   script error, a blocked file, a stalled compositor — leaves a portfolio
   showing nothing at all. Here, every one of those failures degrades to
   plain visible content.                                                   */

html[data-animate] .reveal {
  opacity: 0;
  transform: translateY(1.25rem);
  transition:
    opacity var(--t-reveal) var(--ease-out),
    transform var(--t-reveal) var(--ease-out);
}
html[data-animate] .reveal.is-in { opacity: 1; transform: none; }

/* Staggered children, capped — a long stagger reads as a slow site. */
html[data-animate] .reveal.is-in > * {
  animation: reveal-child var(--t-reveal) var(--ease-out) both;
}
html[data-animate] .reveal.is-in > *:nth-child(1) { animation-delay: 0ms; }
html[data-animate] .reveal.is-in > *:nth-child(2) { animation-delay: 60ms; }
html[data-animate] .reveal.is-in > *:nth-child(3) { animation-delay: 120ms; }
html[data-animate] .reveal.is-in > *:nth-child(n+4) { animation-delay: 180ms; }

@keyframes reveal-child {
  from { opacity: 0; transform: translateY(0.75rem); }
  to   { opacity: 1; transform: none; }
}
