/* ==========================================================================
   styles.css — base + components.
   Rules: tokens only (no raw values), logical properties only (RTL-safe).
   ========================================================================== */

/* ------ Fonts: self-hosted Inter ------
   Downloaded from rsms.me/inter/ rather than served via Google Fonts —
   one variable file covers the full weight range (100–900) instead of
   separate static files per weight. There is deliberately no italic face,
   and nothing in the stylesheet asks for one any more: the two former users
   (.draft-note, and <em> in a case study) now carry their emphasis through
   a real instance of the variable font instead of the browser's synthetic
   oblique — see the `em` rule below and .draft-note further down. A faux
   oblique is a mechanically slanted upright, and slanting a grotesque that
   was never drawn slanted is exactly the artefact a portfolio built on
   self-hosted variable fonts should not be showing.
   See docs/FONT-LOADING-PERF-PLAN.md for the full before/after.

   InterVariable.woff2, Commissioner-Variable.woff2, and the IBM Plex Mono
   files below are all Latin-subsetted (fontTools
   pyftsubset — Basic Latin, Latin-1 Supplement, Latin Extended-A,
   General Punctuation, Currency Symbols, Arrows, Geometric Shapes) —
   a one-time, manual regeneration of the asset, not a build step. Full
   Unicode originals covered dozens of scripts (Cyrillic, Greek,
   Vietnamese...) this Latin-only site never renders through them —
   Arabic is Reem Kufi's and IBM Plex Sans Arabic's job entirely, never these three.
   Re-run the subset (command in the perf plan doc) if a future edit needs
   a character outside that range; the font will otherwise silently fall
   back to the browser's system sans/mono for just that character. */
@font-face {
  font-family: "InterVariable";
  font-style: normal;
  font-weight: 100 900;
  font-display: swap;
  src: url("../assets/fonts/InterVariable.woff2") format("woff2");
}

/* ------ Fonts: self-hosted Commissioner, IBM Plex Mono ------
   Commissioner (headings + button/link labels) downloaded from its GitHub
   source repo (kosbarts/Commissioner) — a single variable-font file, same
   pattern as InterVariable above. IBM Plex Mono (all monospace/meta text)
   ships static weights on Google Fonts (no variable axis), so it gets two
   @font-face rules instead of one — Regular for body-weight mono text,
   Medium covering 500-900 for anything that needs to read as emphasised
   (nothing does yet, but the range costs nothing extra to declare). */
@font-face {
  font-family: "Commissioner";
  font-style: normal;
  font-weight: 100 900;
  font-display: swap;
  src: url("../assets/fonts/Commissioner-Variable.woff2") format("woff2");
}
/* Reem Kufi (RTL headings/buttons, substituting for Commissioner) and IBM
   Plex Sans Arabic (RTL body/eyebrow/mono, substituting for Inter and IBM
   Plex Mono) — both from Google Fonts, Arabic-subset only (unicode-range
   trimmed to the "arabic" block Google itself serves), downloaded and
   self-hosted per this site's convention. Reem Kufi ships as a single
   variable file (400-700); Plex Sans Arabic ships static weights, so one
   @font-face per weight actually used on the site. */
@font-face {
  font-family: "Reem Kufi";
  font-style: normal;
  font-weight: 400 700;
  font-display: swap;
  src: url("../assets/fonts/ReemKufi-Variable.woff2") format("woff2");
  unicode-range: U+0600-06FF, U+0750-077F, U+0870-088E, U+0890-0891, U+0897-08E1, U+08E3-08FF, U+200C-200E, U+2010-2011, U+204F, U+2E41, U+FB50-FDFF, U+FE70-FE74, U+FE76-FEFC, U+102E0-102FB, U+10E60-10E7E, U+10EC2-10EC4, U+10EFC-10EFF, U+1EE00-1EE03, U+1EE05-1EE1F, U+1EE21-1EE22, U+1EE24, U+1EE27, U+1EE29-1EE32, U+1EE34-1EE37, U+1EE39, U+1EE3B, U+1EE42, U+1EE47, U+1EE49, U+1EE4B, U+1EE4D-1EE4F, U+1EE51-1EE52, U+1EE54, U+1EE57, U+1EE59, U+1EE5B, U+1EE5D, U+1EE5F, U+1EE61-1EE62, U+1EE64, U+1EE67-1EE6A, U+1EE6C-1EE72, U+1EE74-1EE77, U+1EE79-1EE7C, U+1EE7E, U+1EE80-1EE89, U+1EE8B-1EE9B, U+1EEA1-1EEA3, U+1EEA5-1EEA9, U+1EEAB-1EEBB, U+1EEF0-1EEF1;
}
@font-face {
  font-family: "IBM Plex Sans Arabic";
  font-style: normal;
  font-weight: 300;
  font-display: swap;
  src: url("../assets/fonts/IBMPlexSansArabic-Light.woff2") format("woff2");
  unicode-range: U+0600-06FF, U+0750-077F, U+0870-088E, U+0890-0891, U+0897-08E1, U+08E3-08FF, U+200C-200E, U+2010-2011, U+204F, U+2E41, U+FB50-FDFF, U+FE70-FE74, U+FE76-FEFC, U+102E0-102FB, U+10E60-10E7E, U+10EC2-10EC4, U+10EFC-10EFF, U+1EE00-1EE03, U+1EE05-1EE1F, U+1EE21-1EE22, U+1EE24, U+1EE27, U+1EE29-1EE32, U+1EE34-1EE37, U+1EE39, U+1EE3B, U+1EE42, U+1EE47, U+1EE49, U+1EE4B, U+1EE4D-1EE4F, U+1EE51-1EE52, U+1EE54, U+1EE57, U+1EE59, U+1EE5B, U+1EE5D, U+1EE5F, U+1EE61-1EE62, U+1EE64, U+1EE67-1EE6A, U+1EE6C-1EE72, U+1EE74-1EE77, U+1EE79-1EE7C, U+1EE7E, U+1EE80-1EE89, U+1EE8B-1EE9B, U+1EEA1-1EEA3, U+1EEA5-1EEA9, U+1EEAB-1EEBB, U+1EEF0-1EEF1;
}
@font-face {
  font-family: "IBM Plex Sans Arabic";
  font-style: normal;
  font-weight: 400;
  font-display: swap;
  src: url("../assets/fonts/IBMPlexSansArabic-Regular.woff2") format("woff2");
  unicode-range: U+0600-06FF, U+0750-077F, U+0870-088E, U+0890-0891, U+0897-08E1, U+08E3-08FF, U+200C-200E, U+2010-2011, U+204F, U+2E41, U+FB50-FDFF, U+FE70-FE74, U+FE76-FEFC, U+102E0-102FB, U+10E60-10E7E, U+10EC2-10EC4, U+10EFC-10EFF, U+1EE00-1EE03, U+1EE05-1EE1F, U+1EE21-1EE22, U+1EE24, U+1EE27, U+1EE29-1EE32, U+1EE34-1EE37, U+1EE39, U+1EE3B, U+1EE42, U+1EE47, U+1EE49, U+1EE4B, U+1EE4D-1EE4F, U+1EE51-1EE52, U+1EE54, U+1EE57, U+1EE59, U+1EE5B, U+1EE5D, U+1EE5F, U+1EE61-1EE62, U+1EE64, U+1EE67-1EE6A, U+1EE6C-1EE72, U+1EE74-1EE77, U+1EE79-1EE7C, U+1EE7E, U+1EE80-1EE89, U+1EE8B-1EE9B, U+1EEA1-1EEA3, U+1EEA5-1EEA9, U+1EEAB-1EEBB, U+1EEF0-1EEF1;
}
@font-face {
  font-family: "IBM Plex Sans Arabic";
  font-style: normal;
  font-weight: 500;
  font-display: swap;
  src: url("../assets/fonts/IBMPlexSansArabic-Medium.woff2") format("woff2");
  unicode-range: U+0600-06FF, U+0750-077F, U+0870-088E, U+0890-0891, U+0897-08E1, U+08E3-08FF, U+200C-200E, U+2010-2011, U+204F, U+2E41, U+FB50-FDFF, U+FE70-FE74, U+FE76-FEFC, U+102E0-102FB, U+10E60-10E7E, U+10EC2-10EC4, U+10EFC-10EFF, U+1EE00-1EE03, U+1EE05-1EE1F, U+1EE21-1EE22, U+1EE24, U+1EE27, U+1EE29-1EE32, U+1EE34-1EE37, U+1EE39, U+1EE3B, U+1EE42, U+1EE47, U+1EE49, U+1EE4B, U+1EE4D-1EE4F, U+1EE51-1EE52, U+1EE54, U+1EE57, U+1EE59, U+1EE5B, U+1EE5D, U+1EE5F, U+1EE61-1EE62, U+1EE64, U+1EE67-1EE6A, U+1EE6C-1EE72, U+1EE74-1EE77, U+1EE79-1EE7C, U+1EE7E, U+1EE80-1EE89, U+1EE8B-1EE9B, U+1EEA1-1EEA3, U+1EEA5-1EEA9, U+1EEAB-1EEBB, U+1EEF0-1EEF1;
}
@font-face {
  font-family: "IBM Plex Sans Arabic";
  font-style: normal;
  font-weight: 600;
  font-display: swap;
  src: url("../assets/fonts/IBMPlexSansArabic-SemiBold.woff2") format("woff2");
  unicode-range: U+0600-06FF, U+0750-077F, U+0870-088E, U+0890-0891, U+0897-08E1, U+08E3-08FF, U+200C-200E, U+2010-2011, U+204F, U+2E41, U+FB50-FDFF, U+FE70-FE74, U+FE76-FEFC, U+102E0-102FB, U+10E60-10E7E, U+10EC2-10EC4, U+10EFC-10EFF, U+1EE00-1EE03, U+1EE05-1EE1F, U+1EE21-1EE22, U+1EE24, U+1EE27, U+1EE29-1EE32, U+1EE34-1EE37, U+1EE39, U+1EE3B, U+1EE42, U+1EE47, U+1EE49, U+1EE4B, U+1EE4D-1EE4F, U+1EE51-1EE52, U+1EE54, U+1EE57, U+1EE59, U+1EE5B, U+1EE5D, U+1EE5F, U+1EE61-1EE62, U+1EE64, U+1EE67-1EE6A, U+1EE6C-1EE72, U+1EE74-1EE77, U+1EE79-1EE7C, U+1EE7E, U+1EE80-1EE89, U+1EE8B-1EE9B, U+1EEA1-1EEA3, U+1EEA5-1EEA9, U+1EEAB-1EEBB, U+1EEF0-1EEF1;
}
@font-face {
  font-family: "IBM Plex Mono";
  font-style: normal;
  font-weight: 400;
  font-display: swap;
  src: url("../assets/fonts/IBMPlexMono-Regular.woff2") format("woff2");
}
@font-face {
  font-family: "IBM Plex Mono";
  font-style: normal;
  font-weight: 500 900;
  font-display: swap;
  src: url("../assets/fonts/IBMPlexMono-Medium.woff2") format("woff2");
}

/* ------ Fonts: self-hosted Architects Daughter (handwriting) ------
   Downloaded from Google Fonts' own CDN (the "latin" subset — Basic Latin,
   Latin-1 Supplement, and matching punctuation, the same scope the other
   Latin-subsetted fonts above cover), not linked live, so it follows the
   same self-hosting rule as every other typeface here. A monoline
   architect's-hand face, not a flowing cursive (Caveat, used here until
   this swap) — fits the drafting-table material system this site now
   runs on. Used only for handwritten annotation text (.sticky-note,
   .highlight-card), never for headings or body copy, and never under
   [dir="rtl"] — see the RTL override further down. Ships only a Regular
   weight upstream — there is no bold cut. */
@font-face {
  font-family: "Architects Daughter";
  font-style: normal;
  font-weight: 400;
  font-display: swap;
  src: url("../assets/fonts/ArchitectsDaughter-Regular.woff2") format("woff2");
}

/* ------ Reset & base ------ */
*,
*::before,
*::after {
  box-sizing: border-box;
  margin: 0;
}

/* ------ The cutting mat ------
   The background colour MUST live on <html>. Without it, <body>'s background
   propagates to the canvas and the paper would fill the whole viewport
   instead of sitting on the mat.

   The artwork rides on a fixed pseudo-element rather than
   `background-attachment: fixed`, which iOS Safari degrades to `scroll` and
   then stretches a `cover` image down the full document height. This is a
   static layer, not parallax — parallax is banned in DESIGN-GUIDELINES §8.

   --mat-image is the ruled-mat SVG (grid, axis numbers, corner protractor —
   see tokens.css). It renders once (`no-repeat`) and is scaled to fully
   cover the viewport (`background-size: cover`), anchored `left bottom` so
   the mat's ruled origin always sits at the actual bottom-left of the
   screen instead of tiling in repeated chunks. js/main.js overwrites this
   custom property with a freshly-built, recolored version when a visitor
   picks a color in the surface picker.

   --mat-texture is a second, independent layer: a tileable noise panel
   (see tokens.css) giving the mat a mottled, rubberized surface instead
   of a flat vector fill. It repeats at its own tile size while
   --mat-image covers, and `background-blend-mode: screen` makes it read
   correctly against the grid's fixed color AND any user-picked mat
   color — every mat color is dark-to-mid, never near-white, and screen
   is the blend mode that stays visible against a dark backdrop (see the
   longer contrast writeup on --paper-texture in tokens.css, which uses
   the same mat-vs-paper reasoning). Neither this rule nor js/main.js has
   to know the mat's actual color for the texture to read correctly. */
html {
  scroll-behavior: smooth;
  background-color: var(--color-mat);
  /* Set once on the root so every surface inherits it. On macOS the default
     subpixel rendering draws Inter heavier than drawn, which on a warm
     parchment sheet reads as smudged ink rather than crisp type. */
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
}
@media (prefers-reduced-motion: reduce) {
  html {
    scroll-behavior: auto;
  }
  /* Every transition/animation in this file is built on these three duration
     tokens (verified — none hardcodes a duration), so collapsing them to
     near-zero here is the single site-wide "no motion" switch: nothing to
     hand-wrap per rule, and anything added later inherits the same off
     switch automatically. 1ms rather than 0s because the sticky-note tip's
     `allow-discrete` transition on `display` (below) needs a non-zero
     duration to actually run — 0s can make the browser skip the discrete
     step and never show the element at all. */
  :root {
    --duration-fast: 1ms;
    --duration-base: 1ms;
    --duration-reveal: 1ms;
  }
}

html::before {
  content: "";
  position: fixed;
  inset: 0;
  z-index: -1;
  background-image: var(--mat-texture), var(--mat-image);
  background-repeat: repeat, no-repeat;
  background-position: left top, left bottom;
  background-size: 300px 300px, cover;
  background-blend-mode: screen, normal;
}
/* Below 48em the paper is full-bleed and the mat can never be seen — it's
   already inlined in this stylesheet either way (no separate request to
   save), but there's no reason to pay the tiling paint cost for a layer
   nothing can see. */
@media (max-width: 47.99em) {
  html::before {
    background-image: none;
  }
  /* Below this same width the paper runs edge-to-edge with the viewport
     (--paper-inline: 100%) — the scrapbook photos in the About section
     (below) deliberately bleed past the paper's edge at wider breakpoints,
     and with no margin left for that here, "past the edge" means "past the
     viewport". `overflow: clip` on both axes crops it rather than opening
     horizontal scroll. `clip`, not `hidden`: hidden creates a scrollable
     overflow region (and can trigger the visible/non-visible axis
     mismatch rule turning the other axis into auto); clip has no scroll
     semantics at all, which is what a page that's meant to scroll only via
     <html> actually wants. Both axes are set even though only inline
     overflow is expected, because leaving block on `visible` while inline
     is non-visible forces UAs to compute the visible one as auto instead —
     exactly the accidental-scroll-container failure mode this is here to
     avoid.

     This is set on #main, NOT <body> or <html> — deliberately, and it took
     two tries to land here. `<body>` was the original choice, but per the
     CSS Overflow spec, when the root element's overflow is `visible` (the
     default — nothing sets one) and it has a <body> child, the UA
     propagates BODY's overflow up to become the viewport's own used
     overflow (with `clip` becoming `hidden` in that propagated form), and
     body itself reverts to `visible`. iOS Safari has long-standing WebKit
     bugs around `overflow: hidden` on the root scroller where native
     touch-scroll gestures lock up on load and only a pinch-zoom (a
     different gesture path) kicks the layout into recognizing it's
     scrollable — exactly the "stuck on the first section until I pinched"
     report this rule caused. Moving it to <html> instead (tried first)
     avoids propagation but is worse: the CSS spec defines `clip` as having
     "no scrolling user interface" at all, and applied straight to the
     actual root scroller that breaks scrolling outright everywhere, not
     just iOS — verified directly: combined with this site's global
     `scroll-behavior: smooth` (html rule near the top of this file),
     `window.scrollTo()` silently no-ops, which would have broken every
     in-page anchor link (nav, footer, skip link) on mobile. #main is a
     plain descendant box: clipping it never touches the viewport's own
     overflow (that
     propagation rule is body-to-viewport only), it isn't the containing
     block for anything that needs to escape it (the lightbox/tool-popover
     <dialog>s render in the top layer regardless of DOM position, and
     .surface-picker plus reparented in-drag stickers live outside #main
     entirely — see js/main.js §9), and it still crops the exact same
     bleeding content since every section that bleeds (.about-photos,
     .sticker-board) lives inside it. */
  #main {
    overflow: clip;
  }
}

/* ------ The paper ------
   <body> is the sheet. `container-type: inline-size` is what lets headings and
   page margins be sized in cqi against the paper rather than the viewport.
   It does NOT make <body> a containing block for positioned descendants —
   verified directly (an absolutely-positioned probe with inset 0/0 lands at
   the document origin, not body's own box, despite container-type being
   set) — so anything that needs to reach the paper's own edges (the About
   section's scrapbook photos, below, bleed past it with a negative margin)
   must place itself relative to a real ancestor in the normal flow, not
   assume <body> will catch it. The mat hangs off <html> rather than <body>
   for an unrelated reason: it's a fixed backdrop meant to fill the viewport
   regardless of where the paper itself scrolls to. */
/* The paper's own lit-fibre relief (--paper-texture, see tokens.css) is a
   second background layer over the flat --color-bg fill, tiled at its own
   size. Unlike the old flat-grain version, `background-blend-mode: normal`
   is NOT themed — this is the blend mode the feDiffuseLighting technique
   actually needs (it alpha-composites the lit pixel straight over
   --color-bg, matching how marijanapav.com/stamps's own reference effect
   composites), so it stays constant across themes. What changes per theme
   is baked into the token itself: --paper-texture's own feColorMatrix
   carries a different constant alpha per theme (0.20 light / 0.04 dark —
   tokens.css has the full worst-single-pixel contrast derivation, checked
   against body text, secondary text, AND the accent). */
body {
  inline-size: var(--paper-inline);
  margin-inline: auto;
  margin-block-start: var(--paper-inset-block-start);
  margin-block-end: 0;
  min-block-size: 100vh;
  container-type: inline-size;
  background-color: var(--color-bg);
  background-image: var(--paper-texture);
  background-repeat: repeat;
  background-size: 240px 240px;
  background-blend-mode: normal;
  box-shadow: var(--shadow-paper);
  color: var(--color-text);
  font-family: var(--font-sans);
  font-size: var(--text-body);
  font-weight: 400;
  letter-spacing: var(--tracking-body);
  line-height: var(--leading-body);
  transition: background-color var(--duration-base) var(--ease),
    color var(--duration-base) var(--ease);
}

img {
  display: block;
  max-inline-size: 100%;
  block-size: auto;
}

/* Headings opt into Inter's full character-variant bundle (see
   --font-feature-alt) — the modern stand-in for the old "Inter UI var alt"
   cut — and run their own -2.5% tracking (--tracking-heading) rather than
   inheriting body's -2%. Weight steps down the hierarchy: Medium (500) for
   h1/h2, Semibold (600) for h3 and everything smaller. */
h1,
h2,
h3,
h4,
h5,
h6 {
  font-family: var(--font-heading);
  line-height: var(--leading-tight);
  text-wrap: balance;
  font-weight: 600;
  letter-spacing: var(--tracking-heading);
  font-feature-settings: var(--font-feature-alt);
}
h1,
h2 {
  font-weight: 500;
}

/* `pretty` keeps a paragraph from ending on a single orphaned word. It is
   applied to paragraphs only — headings get `balance` above, and long-form
   prose gets neither, since browsers stop balancing past a few lines anyway. */
p {
  max-inline-size: var(--measure);
  text-wrap: pretty;
}

/* Emphasis without an italic face. Inter ships here as a single variable
   file covering 100–900, so a weight step is a genuine instance of the
   typeface; `font-style: italic` would instead have the browser synthesize a
   slant. Weight also survives better at the sizes <em> actually appears at
   here (14–16px body copy), where a faux oblique mostly reads as blur.
   kami's own answer is brand-coloured normal-style emphasis, which is not
   reused here: on this site brand colour means "link", and spending it on
   non-links would break that contract. */
em {
  font-style: normal;
  font-weight: 500;
}

/* Monospace text (inline code, code blocks, the .mono utility) shouldn't
   inherit body's -2% tracking — negative letter-spacing on a fixed-width
   face just cramps character spacing that's supposed to stay uniform. */
code,
pre,
.mono {
  letter-spacing: normal;
}

/* ------ Focus: visible everywhere, both themes ------ */
:focus-visible {
  outline: var(--focus-ring);
  outline-offset: var(--focus-offset);
  border-radius: var(--radius-sm);
}

/* The lightbox scrim is deliberately theme-invariant (near-opaque black in
   both themes — see --color-scrim in tokens.css), so the theme's own focus
   colour is the wrong one to use inside it. In light theme that is ink blue,
   which measures 1.52:1 against the scrim: a keyboard user tabbing to the
   close button would get a ring they cannot see. The scrim's own foreground
   token is the right colour here, and it is theme-invariant for the same
   reason the scrim is. */
.lightbox :focus-visible {
  outline-color: var(--color-on-scrim);
}

/* ------ Forced colours (Windows High Contrast) ------
   In forced-colors mode the OS overrides background-color and drops
   box-shadow, so a card carried purely by its fill — which is how every card
   on this site now works — would lose all definition and read as loose text
   on the page. Borders survive the override, so they are restored here and
   only here. This is the one place the no-closed-border rule is traded away,
   because in this mode the fill it depends on does not exist. */
@media (forced-colors: active) {
  .case-stat,
  .compare-col,
  .status-callout,
  .tag-pill,
  .cert-status {
    border: 1px solid CanvasText;
  }
  /* Keep the ring visible against whatever the OS paints behind it. */
  :focus-visible {
    outline-color: Highlight;
  }
}

/* ------ Utilities ------ */
/* The paper is now the width authority, so .container stops capping width and
   instead supplies the page margins — which scale with the sheet, so no
   breakpoints are needed. */
.container {
  margin-inline: auto;
  padding-inline: var(--paper-margin-inline);
}

.visually-hidden {
  position: absolute;
  inline-size: 1px;
  block-size: 1px;
  padding: 0;
  margin: -1px;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
}

.mono {
  font-family: var(--font-mono);
  font-size: var(--text-mono);
}

.eyebrow {
  font-family: var(--font-mono);
  font-size: var(--text-mono);
  text-transform: uppercase;
  letter-spacing: 0.08em;
  color: var(--color-text-secondary);
  display: block;
  margin-block-end: var(--space-05);
}

/* The Skills section's eyebrow ships as two elements (index.html), not one
   swapped by JS — a live media query, not a one-time check, so the correct
   copy shows even across a resize/DevTools device-toolbar toggle with no
   reload, matching js/main.js §9's own live 48em check that actually gates
   the dragging this text describes (see .draggable's own touch-action
   media query, above `.draggable` in this file, for the matching
   reasoning). Below 48em the stickers aren't draggable, so the copy
   pointing visitors at that has to switch to the "desktop only" version. */
.skills-eyebrow-mobile {
  display: none;
}
@media (max-width: 47.99em) {
  .skills-eyebrow-desktop {
    display: none;
  }
  .skills-eyebrow-mobile {
    display: block;
  }
}

/* Arrows and other directional icons flip in RTL */
[dir="rtl"] .icon-directional {
  display: inline-block;
  transform: scaleX(-1);
}

/* ------ Skip link ------ */
.skip-link {
  position: absolute;
  inset-block-start: var(--space-03);
  inset-inline-start: var(--space-03);
  background: var(--color-accent);
  color: var(--color-on-accent);
  padding: var(--space-03) var(--space-05);
  border-radius: var(--radius-sm);
  text-decoration: none;
  font-weight: 500;
  transform: translateY(-200%);
  z-index: 10;
}
.skip-link:focus-visible {
  transform: translateY(0);
}

/* ------ Header: sticky, hides on scroll-down, reveals on scroll-up ------
   Standard pattern (docs sites, Medium, GitHub): pinned to the viewport top,
   slides away on downward scroll to give content the screen back, and
   reappears the instant the visitor scrolls up even slightly — an upward
   scroll is read as "I want to get back to nav/search", so there's no
   distance threshold on that direction. js/main.js only starts hiding it
   once the visitor is a full viewport height down the page (`--reveal`
   below is unrelated — this is the "first fold" gate mentioned in its own
   comment there); above that, the header just sits pinned with no jitter
   from small scroll adjustments near the top.

   Needs an opaque background now that page content scrolls beneath it —
   the header used to be a normal in-flow block with the paper's own
   background showing through by inheritance; now it has to paint its own. */
.site-header {
  position: sticky;
  inset-block-start: 0;
  z-index: 8; /* below .skip-link (10); above ordinary page content */
  background: var(--color-bg);
  border-block-end: 1px solid var(--color-border);
}
@media (prefers-reduced-motion: no-preference) {
  .site-header {
    transition: transform var(--duration-base) var(--ease),
      box-shadow var(--duration-fast) var(--ease);
  }
}
.site-header.is-header-hidden {
  transform: translateY(-100%);
}
/* "Stuck" (scrolled at all, scrollY > 0) gets the same whisper lift a card
   gets on hover — the one depth cue this site allows itself — so the header
   reads as floating above content rather than as a hard line dropped on top
   of it. Absent at the very top of the page, where the header sits flush in
   its natural resting position and a shadow would have nothing to justify
   it. */
.site-header.is-header-stuck {
  box-shadow: var(--shadow-whisper);
}
.site-header .container {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: var(--space-05);
  padding-block: var(--space-05);
  flex-wrap: wrap;
}
.site-logo {
  font-weight: 600;
  text-decoration: none;
  color: var(--color-text);
  display: inline-flex;
  align-items: center;
  gap: var(--space-02);
}

.site-logo img {
  inline-size: 2.5rem;
}
.site-nav ul {
  display: flex;
  gap: var(--space-06);
  list-style: none;
  padding: 0;
}
.site-nav a {
  display: inline-flex;
  align-items: center;
  min-block-size: 2.75rem;
  color: var(--color-text);
  text-decoration: none;
  font-size: var(--text-body);
}
@media (hover: hover) {
  .site-nav a:hover {
    color: var(--color-accent);
    text-decoration: underline;
    text-underline-offset: 0.2em;
  }
}

.header-controls {
  display: flex;
  gap: var(--space-04);
}

/* ------ Mode select (desktop) ------
   A compact trigger (current selection + chevron) that opens a small
   popover listbox — the "minimal select" look, replacing the old 3-way
   segmented bar at desktop widths. Open/close mechanics mirror the
   surface picker and mobile nav panel elsewhere in this file: trigger
   click toggles, outside click and Escape both close, aria-expanded is
   the single source of truth (js/main.js §2). Hidden below the same
   64em breakpoint the nav hamburger uses — .mobile-controls below takes
   over there instead. */
.mode-select {
  display: none;
  position: relative;
}
@media (min-width: 64em) {
  .mode-select {
    display: inline-flex;
  }
}
.mode-select-trigger {
  display: inline-flex;
  align-items: center;
  gap: var(--space-02);
  background: var(--color-surface-interactive);
  border: 1px solid var(--color-border-strong);
  border-radius: var(--radius-sm);
  corner-shape: superellipse(3);
  color: var(--color-text);
  font-family: var(--font-sans);
  font-size: var(--text-body);
  cursor: pointer;
  padding: var(--space-04) var(--space-05);
  transition: background var(--duration-fast) var(--ease),
    border-color var(--duration-fast) var(--ease);
}
@media (hover: hover) {
  .mode-select-trigger:hover {
    background: var(--color-tint-brand);
    border-color: var(--color-accent);
  }
}
.mode-select-chevron {
  flex-shrink: 0;
  transition: transform var(--duration-fast) var(--ease);
}
.mode-select-trigger[aria-expanded="true"] .mode-select-chevron {
  transform: rotate(180deg);
}
.mode-select-panel {
  position: absolute;
  inset-inline-end: 0;
  inset-block-start: calc(100% + var(--space-02));
  z-index: 20;
  display: flex;
  flex-direction: column;
  gap: var(--space-02);
  min-inline-size: 11rem;
  padding: var(--space-02);
  background: var(--color-bg);
  border: 1px solid var(--color-border);
  border-radius: var(--radius-sm);
  corner-shape: superellipse(3);
  box-shadow: var(--shadow-float);
}
/* [hidden]'s UA-stylesheet `display: none` and this file's own `.mode-select-panel`
   rule above are equal specificity (0,1,0 each) — as author CSS, this one
   always wins the cascade regardless of source order, which silently kept
   the panel visible (and open on page load) despite the `hidden` attribute
   js/main.js sets. The extra attribute selector here outweighs it. */
.mode-select-panel[hidden] {
  display: none;
}
.mode-select-option {
  display: block;
  inline-size: 100%;
  padding: var(--space-03) var(--space-04);
  background: transparent;
  border: none;
  border-radius: var(--radius-sm);
  corner-shape: superellipse(3);
  color: var(--color-text);
  font-family: var(--font-sans);
  font-size: var(--text-body);
  text-align: start;
  cursor: pointer;
  transition: background var(--duration-fast) var(--ease),
    color var(--duration-fast) var(--ease);
}
@media (hover: hover) {
  .mode-select-option:hover {
    background: var(--color-tint-brand);
  }
}
.mode-select-option[aria-pressed="true"] {
  background: var(--color-accent);
  color: var(--color-on-accent);
}
@media (hover: hover) {
  .mode-select-option[aria-pressed="true"]:hover {
    background: var(--color-accent-hover);
  }
}

/* ------ Segmented bar (mobile — language/content mode) ------
   Same pill vocabulary as .btn, stretched full-width with full text
   labels (not the desktop trigger's abbreviated form), used only inside
   .mobile-controls below 64em — the desktop equivalent (.mode-select)
   hides at that width instead of doubling up. */
.segmented-bar {
  display: flex;
  align-items: stretch;
  inline-size: 100%;
  background: var(--color-surface-interactive);
  border: 1px solid var(--color-border-strong);
  border-radius: var(--radius-sm);
  corner-shape: superellipse(3);
}
.segmented-bar-btn {
  flex: 1;
  min-block-size: 3.5rem;
  padding-inline: var(--space-05);
  background: transparent;
  border: none;
  border-inline-start: 1px solid var(--color-border-strong);
  color: var(--color-text);
  font-size: var(--text-lead);
  font-weight: 600;
  cursor: pointer;
  transition: background var(--duration-fast) var(--ease),
    color var(--duration-fast) var(--ease);
}
.segmented-bar-btn:first-child {
  border-inline-start: none;
  border-start-start-radius: var(--radius-sm);
  border-end-start-radius: var(--radius-sm);
  corner-shape: superellipse(3);
}
.segmented-bar-btn:last-child {
  border-start-end-radius: var(--radius-sm);
  border-end-end-radius: var(--radius-sm);
  corner-shape: superellipse(3);
}
@media (hover: hover) {
  .segmented-bar-btn:hover {
    background: var(--color-tint-brand);
  }
}
.segmented-bar-btn[aria-pressed="true"] {
  background: var(--color-accent);
  color: var(--color-on-accent);
}
@media (hover: hover) {
  .segmented-bar-btn[aria-pressed="true"]:hover {
    background: var(--color-accent-hover);
  }
}
.mobile-controls {
  display: none;
}
@media (max-width: 63.99em) {
  .mobile-controls {
    display: flex;
    flex-direction: column;
    gap: var(--space-04);
    inline-size: 100%;
  }
  /* The desktop-only control this replaces at narrow widths. */
  .mode-select {
    display: none;
  }
}

/* ------ Mobile/tablet nav (hamburger) ------
   Purely additive, same rule this codebase already applies to the
   lightbox and the drag engine: `.site-nav-panel` defaults to
   `display: contents` at EVERY width, so absent js/main.js §12 the
   header renders exactly as it always has — nav links and the lang/theme
   toggles in their normal flow position, wrapping at narrow widths same
   as before, nothing collapsed, nothing to click. Only once that script
   runs does `.site-header` gain `.nav-js-ready`, which is what actually
   turns the hamburger button on and the panel into a real toggleable
   dropdown below `64em` — both gated behind that one class, so there is
   no state to reconcile if JS never runs. */
.nav-toggle {
  display: none;
  align-items: center;
  justify-content: center;
  padding: var(--space-04);
  background: var(--color-surface-interactive);
  border: 1px solid var(--color-border-strong);
  border-radius: var(--radius-sm);
  corner-shape: superellipse(3);
  cursor: pointer;
}
.nav-toggle-icon,
.nav-toggle-icon::before,
.nav-toggle-icon::after {
  display: block;
  inline-size: 1.125rem;
  block-size: 2px;
  border-radius: 1px;
  background: var(--color-text);
}
@media (prefers-reduced-motion: no-preference) {
  .nav-toggle-icon,
  .nav-toggle-icon::before,
  .nav-toggle-icon::after {
    transition: transform var(--duration-fast) var(--ease), opacity var(--duration-fast) var(--ease);
  }
}
.nav-toggle-icon {
  position: relative;
}
.nav-toggle-icon::before,
.nav-toggle-icon::after {
  content: "";
  position: absolute;
  inset-inline-start: 0;
}
.nav-toggle-icon::before {
  inset-block-start: -6px;
}
.nav-toggle-icon::after {
  inset-block-start: 6px;
}
/* Hamburger → X, driven off the trigger's own aria-expanded rather than a
   separate JS-toggled class — one state, already required for a11y,
   instead of two that could drift out of sync. */
.nav-toggle[aria-expanded="true"] .nav-toggle-icon {
  background: transparent;
}
.nav-toggle[aria-expanded="true"] .nav-toggle-icon::before {
  transform: translateY(6px) rotate(45deg);
}
.nav-toggle[aria-expanded="true"] .nav-toggle-icon::after {
  transform: translateY(-6px) rotate(-45deg);
}

.site-nav-panel {
  display: contents;
}
@media (max-width: 63.99em) {
  .site-header.nav-js-ready .nav-toggle {
    display: inline-flex;
  }
  .site-header.nav-js-ready .site-nav-panel {
    display: flex;
    flex-direction: column;
    align-items: center;
    position: absolute;
    inset-inline: 0;
    inset-block-start: 100%;
    padding: var(--space-08) var(--paper-margin-inline) var(--space-09);
    background: var(--color-bg);
    border-block-end: 1px solid var(--color-border);
    box-shadow: var(--shadow-float);
  }
  .site-header.nav-js-ready .site-nav-panel[hidden] {
    display: none;
  }
  .site-header.nav-js-ready .site-nav ul {
    flex-direction: column;
    align-items: center;
    gap: var(--space-07);
    text-align: center;
  }
  .site-header.nav-js-ready .site-nav a {
    font-size: var(--text-h2);
  }
  .site-header.nav-js-ready .header-controls {
    display: flex;
    flex-direction: column;
    align-items: center;
    inline-size: 100%;
    margin-block-start: var(--space-08);
  }
  /* Breaks out of .site-nav-panel's own (fairly generous) side padding so
     the segmented control reads as full-width, with only a minimum
     padding of its own — not the same inset the nav links above it use. */
  .site-header.nav-js-ready .mobile-controls {
    inline-size: 100%;
    margin-inline: calc(-1 * var(--paper-margin-inline));
    padding-inline: var(--space-04);
  }
}
@media (min-width: 64em) {
  /* Desktop always shows the nav, regardless of whatever `hidden` state
     a visitor's last mobile-width interaction left behind — resizing
     from mobile to tablet-open to desktop must never strand the nav
     hidden. js/main.js §12 also actively closes the panel on this same
     64em crossing, so this is a CSS backstop, not the only guard. */
  .site-nav-panel[hidden] {
    display: contents;
  }
}

/* ------ Sections ------ */
.section {
  padding-block: var(--section-rhythm);
  border-block-end: 1px solid var(--color-border);
}
/* The rule under the final section used to butt against the page edge; on
   paper it reads as a line left stranded mid-sheet. */
.section:last-of-type {
  border-block-end: 0;
}

/* ------ Hero ------ */
.hero-identity {
  display: flex;
  align-items: flex-end;
  gap: var(--space-05);
  margin-block-end: var(--space-07);
}
.hero-avatar img {
  inline-size: 8rem;
  block-size: auto;
}
/* Below 48em, the avatar stacks above the name/role text instead of
   sitting beside it. The old approach kept them side by side and drove
   the avatar's width off the row's stretched height (to match however
   tall the wrapping text got) — technically non-distorting (inline-size:
   auto preserves the intrinsic ratio), but it read as stretching because
   the avatar ballooned to whatever height the text stack happened to
   wrap to. Stacking removes that coupling entirely: the avatar keeps its
   own fixed width and true aspect ratio regardless of how the text wraps. */
@media (max-width: 47.99em) {
  .hero-identity {
    flex-direction: column;
    align-items: flex-start;
  }
}
/* GIF playback can't be paused by CSS, so prefers-reduced-motion swaps to a
   static first frame instead of trying to gate the animation itself. */
.hero-avatar-static {
  display: none;
}
@media (prefers-reduced-motion: reduce) {
  .hero-avatar-motion {
    display: none;
  }
  .hero-avatar-static {
    display: block;
  }
}
.hero-name {
  font-family: var(--font-heading);
  font-weight: 600;
  font-size: var(--text-h3);
  color: var(--color-text);
}
.hero-role {
  color: var(--color-text-secondary);
  text-transform: uppercase;
}

.hero h1 {
  font-size: var(--text-display);
}
/* Intro copy sits on the lead tier (17px), one clear step under h3 (19px)
   and one over body (16px) — it is prose, not a heading. */
.hero .sub {
  margin-block-start: var(--space-06);
  color: var(--color-text-secondary);
  font-size: var(--text-lead);
  font-weight: 400;
}

/* Page-lead: intro paragraph under a section-title. Used on the About/Contact
   redirect stubs. */
.lead {
  margin-block-start: var(--space-06);
  color: var(--color-text-secondary);
  font-size: var(--text-lead);
  font-weight: 400;
  max-inline-size: var(--measure);
}
.hero-actions {
  display: flex;
  flex-wrap: wrap;
  gap: var(--space-04);
  margin-block-start: var(--space-08);
}

/* Two variants, no third: filled and ghost, both pill-shaped. A ghost button
   carries the accent on its border and its label rather than a neutral
   outline, so "this is an action" is signalled by the same ink that signals
   "this is a link" everywhere else on the page.

   The 1.5px ghost border is matched by a 1.5px transparent border on the
   filled variant, so the two sit at identical heights in the same row. */
/* Anatomy: .btn itself carries no padding of its own — every button is
   built from a .btn-label (text, padding: 12px block / 16px inline) and,
   where the button has one, a .btn-icon (a 24px icon, 12px padding on
   all sides, so icon+padding = a 48px square). Both children stretch to
   the row's own height, so an icon-only button and a labelled button
   line up exactly. */
.btn {
  display: inline-flex;
  align-items: stretch;
  border: 1.5px solid transparent;
  border-radius: var(--radius-sm);
  corner-shape: superellipse(3);
  font-family: var(--font-heading);
  font-size: var(--text-body);
  font-weight: 500;
  text-decoration: none;
  transition: background var(--duration-fast) var(--ease),
    color var(--duration-fast) var(--ease),
    border-color var(--duration-fast) var(--ease);
}
.btn-label {
  display: inline-flex;
  align-items: center;
  padding: var(--space-04) var(--space-05);
  white-space: nowrap;
}
.btn-icon {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  flex-shrink: 0;
  padding: var(--space-04);
}
.btn-icon svg {
  inline-size: 1.5rem;
  block-size: 1.5rem;
}
/* Primary is the brand's own green (--color-primary), not the blue link
   accent — a solid-fill CTA reads as the site's identity color, while
   links/focus/secondary buttons keep using --color-accent (blue). */
.btn-primary {
  background: var(--color-primary);
  color: var(--color-on-accent);
  border-color: var(--color-primary);
}
/* Resting fill, not just an outline: --color-tint-brand is the same
   "quietest fill" pale tint already used on hover elsewhere on paper —
   giving it to .btn-secondary at rest too matches the always-filled
   secondary-button look from the paper-stack-lab reference, rather than
   only appearing once a visitor hovers. */
.btn-secondary {
  background: var(--color-tint-brand);
  border-color: var(--color-accent);
  color: var(--color-accent);
}
/* Hover is gated: on a touch screen :hover latches after a tap, so an
   ungated rule leaves a button stuck in its hover colour after it is
   pressed. */
@media (hover: hover) {
  .btn-primary:hover {
    background: var(--color-primary-hover);
    border-color: var(--color-primary-hover);
  }
  .btn-secondary:hover {
    border-color: var(--color-accent-hover);
    color: var(--color-accent-hover);
  }
}

/* Press feedback. Every pressable element on the site gets it, not just the
   two obvious buttons — a control that stays perfectly still under the finger
   reads as one that did not register the press, and having it on some
   controls but not others is worse than not having it at all.
   `.surface-picker-trigger` carries its own scale rule (it also has a hover
   scale) and is not repeated here.

   Motion-safe only, and transform is listed explicitly — never
   `transition: all`, which would also animate layout properties. */
@media (prefers-reduced-motion: no-preference) {
  .btn,
  .mode-select-trigger,
  .mode-select-option,
  .segmented-bar-btn,
  .surface-picker-swatch,
  .surface-picker-reset,
  .lightbox-close {
    transition: background var(--duration-fast) var(--ease),
      color var(--duration-fast) var(--ease),
      border-color var(--duration-fast) var(--ease),
      transform var(--duration-fast) var(--ease);
  }
  .btn:active,
  .mode-select-trigger:active,
  .mode-select-option:active,
  .segmented-bar-btn:active,
  .surface-picker-swatch:active,
  .surface-picker-reset:active,
  .lightbox-close:active {
    transform: scale(0.96);
  }
}

/* ------ Card contract ------
   Cards are a vocabulary, not a per-section choice, so every card on the site
   shares one resting treatment and one hover treatment. Two sections with
   different card physics read as two different products.

   Resting: ivory fill, no closed border. The lift IS the fill — ivory is
   brighter than the parchment it sits on, which is how paper behaves, and it
   avoids stacking an outline on top of a surface change. Cards that are not
   themselves interactive (.case-stat, .compare-col, .status-callout) stop
   here.

   Hover: whisper shadow plus a 1px rise, motion-safe and pointer-gated.
   Interactive cards (.work-card) opt in via .is-liftable. Keyboard users
   get the same treatment through :focus-within, on top of the focus ring
   itself. (.highlight-card moved to the .sticky-note material below — a
   note is paper sitting on the page, not a flat UI card, so it
   deliberately does not share this fill/radius.) */
.case-stat,
.compare-col,
.status-callout {
  background: var(--color-bg-subtle);
  border-radius: var(--radius-md);
}

.is-liftable {
  transition: box-shadow var(--duration-fast) var(--ease),
    background var(--duration-fast) var(--ease);
}
.is-liftable:focus-within {
  box-shadow: var(--shadow-whisper);
}
@media (hover: hover) {
  .is-liftable:hover {
    box-shadow: var(--shadow-whisper);
  }
}
@media (prefers-reduced-motion: no-preference) {
  .is-liftable {
    transition: box-shadow var(--duration-fast) var(--ease),
      background var(--duration-fast) var(--ease),
      transform var(--duration-fast) var(--ease);
  }
  .is-liftable:focus-within {
    transform: translateY(-1px);
  }
  @media (hover: hover) {
    .is-liftable:hover {
      transform: translateY(-1px);
    }
  }
}

/* ------ Work list ------ */
.section-title {
  font-size: var(--text-h2);
  margin-block-end: var(--space-08);
}

.work-list {
  list-style: none;
  padding: 0;
}

/* Each card is a row with a content column and an image column.
   grid-template-columns places content first — the inline-start track —
   so it mirrors to the right automatically under [dir="rtl"] with no
   extra rule, same mechanism the old .work-item relied on for its
   arrow-only RTL override.

   Each row is its own paper sheet now (css/desk.css supplies the stock
   and the stacked/overlapping placement) rather than a flat divided
   list, so there's no hover fill or divider here any more — a sheet of
   paper doesn't recolor when the pointer crosses it. */
.work-card {
  position: relative; /* containment for the card-link ::after */
  display: grid;
  gap: var(--space-05);
}
@container (min-width: 40em) {
  .work-card {
    grid-template-columns: 1fr 1fr;
    align-items: stretch;
    gap: var(--space-07);
  }
}

.work-card-content {
  display: flex;
  flex-direction: column;
  justify-content: space-between;
  gap: var(--space-05);
}

/* Photo-frame treatment, not edge-to-edge: a white mat around a scaled-down
   image, a paper shadow, and a per-card tilt (--base-rot, set in
   css/desk.css) — the same "physical object resting on the page"
   vocabulary as .about-photo/.sticky-note, reused here rather than
   reinvented. Centered in its grid cell (align-self/justify-self override
   the row's own align-items: stretch) so the tilt has room to rotate into
   without clipping against the row's edges.

   Permanently visible, not a hover reveal — the photo is "taped on" the
   sheet from first paint, same as a real desk. display: none below the
   40em container width is still load-bearing, though: it's what keeps
   this image from ever being fetched on a narrow/mobile viewport
   (loading="lazy" + display: none never intersects, so the browser never
   requests it) — nothing about removing the old hover-reveal changes
   that. */
.work-card-media {
  display: none;
  align-self: center;
  justify-self: center;
  inline-size: 85%;
  background: var(--color-bg-subtle);
  padding: var(--space-03) var(--space-03) var(--space-06);
  border-radius: var(--radius-sm);
  box-shadow: var(--shadow-paper);
  --base-rot: 0deg;
  transform: rotate(var(--base-rot));
}
@container (min-width: 40em) {
  .work-card-media {
    display: block;
  }
}
/* Without this, each tile's own intrinsic ratio (a mix of SVG art and a
   .webp photo) decided the box's height, so align-items: stretch on the
   card gave every row a different height depending on which tile it held.
   One fixed ratio for every tile fixes that. */
.work-card-media img {
  display: block;
  aspect-ratio: 4 / 3;
  inline-size: 100%;
  object-fit: cover;
  outline: 1px solid var(--color-image-edge);
  outline-offset: -1px;
}
/* A card with no media at all spans the full row instead of leaving its
   image track an empty hole. */
@container (min-width: 40em) {
  .work-card:not(:has(.work-card-media)) {
    grid-template-columns: 1fr;
  }
  /* Fixed row height so the card's footprint doesn't depend on how much
     description/tag text a given case study happens to have — the photo
     frame centers vertically inside this regardless of content length. */
  .work-card {
    min-block-size: 18rem;
  }
}

.work-card-title {
  font-size: var(--text-h2);
}
/* Card-link pattern: title link's ::after covers the whole row,
   so the row is one tab stop named by the title. */
.work-card-title a {
  color: var(--color-text);
  text-decoration: none;
}
.work-card-title a::after {
  content: "";
  position: absolute;
  inset: 0;
}
@media (hover: hover) {
  .work-card-title a:hover {
    color: var(--color-accent);
  }
}

.work-card-desc {
  color: var(--color-text-secondary);
  font-size: var(--text-small);
  line-height: var(--leading-dense);
}

.work-card-tags {
  display: flex;
  flex-wrap: wrap;
  gap: var(--space-03);
}

/* Concept tags: one fill for both. `.tag-pill--design` / `.tag-pill--engineering`
   are still on the markup (harmless, semantic) but no longer style
   differently — the work-card row uses the tag's own label text ("Design
   System" vs "Code Connect") to carry the distinction, not colour.

   Solid tint fill, no outline: at 12px an outlined pill puts a hairline
   around six words in a row and the eye lands on the shapes before the
   labels.

   Pill-shaped, matching the buttons. Shape is therefore not what separates an
   action from a label here — fill and weight are: buttons carry the accent as
   a solid fill or a 1.5px border and sit at body size, badges carry a flat
   tint at 12px with no border. Keep that distinction when adding either.

   `nowrap` because a two-line tag reads as broken rather than as flowing
   text; the row wraps between tags instead. */
.tag-pill {
  display: inline-flex;
  align-items: center;
  border-radius: var(--radius-pill);
  padding: var(--space-02) var(--space-03);
  font-size: var(--text-mono);
  font-weight: 500;
  letter-spacing: 0.04em;
  white-space: nowrap;
  color: var(--color-concept);
  background: var(--color-tag-bg);
}
[dir="rtl"] .tag-pill {
  letter-spacing: 0;
}

/* ------ Sticky note ------
   One physical-object component, reused wherever the site needs a paper
   note rather than a flat UI card: the homepage stat strip (--stat), the
   drag-anywhere tip (--tip), and a case study's margin annotations
   (--margin). Shares --dx/--dy/--base-rot/--hover-rot/--hover-scale with
   .tool-sticker/.about-photo below — the same "physical object resting on
   the page" transform stack, --radius-sm, not --radius-md (that radius
   belongs to the flat .card-surface family; a note is not a UI card). */
.sticky-note {
  --dx: 0px;
  --dy: 0px;
  --base-rot: 0deg;
  --hover-rot: 0deg;
  --hover-scale: 1;
  position: relative;
  background: var(--color-note-bg);
  color: var(--color-note-text);
  border: 1px solid var(--color-note-edge);
  border-radius: var(--radius-sm);
  box-shadow: var(--shadow-note);
  font-family: var(--font-hand);
  font-size: var(--text-hand);
  transform: translate3d(var(--dx), var(--dy), 0)
    rotate(calc(var(--base-rot) + var(--hover-rot))) scale(var(--hover-scale));
}
/* .reveal/.reveal.is-visible (further down) set `transform` directly for the
   scroll-entrance animation, which would otherwise wipe out the tilt above
   outright (same specificity, later in the cascade). These two-class
   overrides compose both effects instead of picking one; they don't need
   to be near the .reveal rules to win — two classes beats one regardless
   of source order. */
.sticky-note.reveal {
  transform: rotate(var(--base-rot)) translateY(var(--reveal-rise));
}
.sticky-note.reveal.is-visible {
  transform: rotate(var(--base-rot));
}

/* ------ Highlights strip (homepage stat cards) ------ */
.highlight-list {
  list-style: none;
  padding: 0;
  display: grid;
  gap: var(--space-05);
  grid-template-columns: repeat(2, 1fr);
}
@container (min-width: 48em) {
  .highlight-list {
    grid-template-columns: repeat(5, 1fr);
  }
  /* A small bleed past the paper's own edge onto the mat, the same
     cancel-the-container-padding-then-go-further technique #about's
     scrapbook photos use above — just a smaller push (--space-04, not
     --space-09), since these five cards read as one continuous row, not
     a single object meant to dominate the mat. Only above this
     breakpoint: below it the paper runs edge-to-edge and there is no mat
     for the end cards to bleed onto. */
  .highlight-list > :first-child {
    margin-inline-start: calc(-1 * var(--paper-margin-inline) - var(--space-04));
  }
  .highlight-list > :last-child {
    margin-inline-end: calc(-1 * var(--paper-margin-inline) - var(--space-04));
  }
}
.sticky-note--stat {
  display: grid;
  gap: var(--space-03);
  padding: var(--space-06);
}
/* A small, alternating tilt per card — much smaller than the sticker
   board's decorative +-12 deg swing below, since these hold real numbers
   that must stay easy to scan, not a pile of loose stickers. */
.highlight-list .sticky-note--stat:nth-child(5n+1) { --base-rot: -3deg; }
.highlight-list .sticky-note--stat:nth-child(5n+2) { --base-rot: 2deg; }
.highlight-list .sticky-note--stat:nth-child(5n+3) { --base-rot: -1.5deg; }
.highlight-list .sticky-note--stat:nth-child(5n+4) { --base-rot: 3deg; }
.highlight-list .sticky-note--stat:nth-child(5n+5) { --base-rot: -2.5deg; }
/* tabular-nums so the five stat values line up as a row of figures rather
   than as five independently-spaced strings. Stays on --font-heading (not
   --font-hand) — a handwritten face on the large scannable number would
   undercut the "quick read" job this element does. */
.highlight-number {
  font-family: var(--font-heading);
  font-size: var(--text-h3);
  font-weight: 600;
  line-height: var(--leading-tight);
  color: var(--color-note-text);
  font-variant-numeric: tabular-nums;
}
.highlight-label {
  position: static;
  font-size: var(--text-hand);
  line-height: var(--leading-dense);
  color: var(--color-note-text);
  text-decoration: none;
}
.highlight-label::after {
  content: "";
  position: absolute;
  inset: 0;
  border-radius: var(--radius-sm);
}
@media (hover: hover) {
  .highlight-label:hover {
    color: var(--color-accent);
  }
}

/* ------ Tools sticker board ------
   Replaces the old text skill lists: each tool is a logo sticker, scattered
   and made draggable by js/main.js §9 (positions/rotation set there, reset
   on every reload — nothing is persisted). DOM order in index.html stays
   grouped by category so a screen reader still gets a sensible list even
   though the visual layout is randomized. See "Draggable objects" above
   .about-photo for the shared drag/hover mechanics.

   Sized for 19 stickers at their current ~2x scale — bigger than the
   board's first pass, since the scatter grid in js/main.js
   (scatterStickers()) sizes its cells only from the sticker COUNT, not
   their pixel size, so a taller/wider board is what actually gives
   bigger stickers more room per cell (verified in the browser, not just
   calculated — see the DESIGN-GUIDELINES note on this component). */
.sticker-board {
  position: relative;
  min-block-size: clamp(32rem, 80cqi, 46rem);
}
/* Absolute positioning with physical left/top (not logical
   inset-inline-start) is deliberate here, unlike everywhere else on this
   site: these coordinates come from a random scatter with no inherent
   reading direction, so there's nothing for RTL to mirror.

   .tool-sticker is a real <button> (see index.html), not a bare <img> —
   it's keyboard-focusable and opens the tool-info popover (js/main.js
   §11) on click/Enter/Space, unlike .about-photo below, which stays
   purely decorative. The reset properties here (appearance/border/
   background/padding/font) make it render identically to the plain
   image it replaced; :focus-visible needs no extra rule since the
   site's global one already covers any newly-focusable element. */
.tool-sticker {
  position: absolute;
  inline-size: clamp(7rem, 18cqi, 11rem);
  block-size: auto;
  display: block;
  appearance: none;
  border: none;
  background: none;
  padding: 0;
  margin: 0;
  font: inherit;
  --dx: 0px;
  --dy: 0px;
  --base-rot: 0deg;
  --hover-rot: 0deg;
  --hover-scale: 1;
  transform: translate3d(var(--dx), var(--dy), 0)
    rotate(calc(var(--base-rot) + var(--hover-rot))) scale(var(--hover-scale));
}
.tool-sticker-img {
  display: block;
  inline-size: 100%;
  block-size: auto;
  pointer-events: none; /* clicks/taps always resolve to the button, never the image */
}
/* Below 48em, the grid-cell math scatterStickers() (js/main.js) relies on
   breaks down: a sticker's own minimum width (7rem) is often wider than
   the board's own scatter cell at this width, so "each sticker gets its
   own cell" no longer holds and stickers overlap (measured: dozens of
   overlapping pairs at 375px). js/main.js skips scatterStickers() below
   this same breakpoint, leaving every custom property at its zero
   default, so this rule only has to replace the absolute scatter with a
   plain, non-overlapping grid. Dragging itself is desktop-only below this
   same breakpoint (js/main.js §9's own live desktopMQ check on
   makeDraggable — a pointerdown-driven drag fights a touch scroll on a
   phone), so nothing here ever needs to reposition a sticker with an
   inline position: absolute at this width — the plain grid above is the
   only layout a mobile visitor ever sees. */
@media (max-width: 47.99em) {
  .sticker-board {
    display: grid;
    grid-template-columns: repeat(3, 1fr);
    gap: var(--space-04);
    min-block-size: auto;
  }
  .tool-sticker {
    position: static;
    inline-size: 100%;
  }
}

/* ------ Draggable objects ------
   Shared pointer-drag affordance for the About photos below and the Tools
   stickers above — js/main.js §9 wires both up with the same engine. Only
   the interaction states live here; each component supplies its own
   resting tilt/position via --base-rot/--dx/--dy and a `transform` on its
   own base class (.about-photo, .tool-sticker above), so that still
   renders correctly with no JS. `.draggable` itself is added by script
   only once the pointer-drag machinery actually gets wired up (never with
   no JS at all — the same "nothing extra without JS" rule the lightbox
   further down follows), but that wiring now happens unconditionally at
   every viewport width — see `touch-action: none`'s own comment below for
   why. The visible affordance this class carries (cursor: grab, the
   `@media (hover: hover)` tilt below) is harmless where dragging is
   currently inert: cursor has no effect on a touch device with no mouse,
   and hover states are already scoped to hover-capable input.
   z-index sits below the header (8) and surface picker (9) so a dragged
   item never covers either. */
.draggable {
  cursor: grab;
  -webkit-user-drag: none;
  user-select: none;
  transition: transform var(--duration-fast) var(--ease);
}
/* touch-action: none lives in its own media query, not the base rule above,
   and it's the only property that does — deliberately. Both .about-photo
   and .tool-sticker now get `.draggable` added UNCONDITIONALLY (js/main.js
   §9 always calls makeDraggable(el, { desktopOnly: true }) for each), on
   purpose: makeDraggable's own pointerdown handler re-checks the current
   viewport live, via a MediaQueryList, on every press — not a one-time
   flag read at setup — so a single instance keeps behaving correctly
   across a resize or DevTools device-toolbar toggle with no reload. A
   plain, unconditional `touch-action: none` on the base rule would undo
   exactly that: the property is static CSS, so it would keep blocking
   native touch-scroll on a narrow viewport regardless of what the live JS
   check decides, recreating the original "stuck on the first section, had
   to pinch" bug for anyone who loads the page wide and later narrows it.
   Scoping it to this same 48em breakpoint keeps it just as live as the JS
   check it has to match. */
@media (min-width: 48em) {
  .draggable {
    touch-action: none;
  }
}
.draggable.is-detached {
  z-index: 6;
}
/* Same specificity as .is-detached (both are ".draggable" + one modifier
   class) — declared second so source order breaks the tie in its favor,
   since an item actively being dragged should sit above any other already-
   dropped item, not tie with it. */
.draggable.is-dragging {
  transition: none;
  cursor: grabbing;
  z-index: 7;
}
@media (hover: hover) {
  .draggable:hover {
    --hover-rot: 3deg;
    --hover-scale: 1.06;
  }
}
@media (prefers-reduced-motion: reduce) {
  .draggable {
    transition: none;
  }
}

/* ------ About ------ */
/* In-text links: underlined always (never color alone) */
.prose a {
  color: var(--color-accent);
  text-decoration: underline;
  text-underline-offset: 0.15em;
}
@media (hover: hover) {
  .prose a:hover {
    color: var(--color-accent-hover);
  }
}

/* ------ About: text column + scrapbook photos ------
   Two photos styled like physical prints — tilted, stacked one below the
   other, taped onto the paper with a corner deliberately bleeding past its
   edge onto the mat behind it. This is the one place on the site that
   breaks the "whisper shadow only, never a hard drop" rule on purpose:
   these are meant to read as objects sitting ON TOP of the page, not as a
   card within the page's own flat design system — the same "a sheet resting
   on a surface" logic that gives the page itself --shadow-paper, one level
   up. Reusing that exact token here (rather than inventing a second
   dramatic shadow) is deliberate: one idea, applied twice, at two scales.

   #about is scoped throughout — .prose is shared with every case-study body
   (work/*.html) and shouldn't pick up rules meant for exactly one section on
   one page.

   Text and photos are two SEPARATE grid tracks at desktop, not one shared
   space with a photo floated over part of it — an earlier version tried the
   latter (text left to find its own width, photos floated beside it) and it
   collided with real paragraph text at the paper's own 44rem floor width
   (measured: 90px of overlap, not theoretical). Reserving space
   structurally, in two dedicated columns, makes that collision impossible
   by construction rather than something to keep re-verifying: .about-text
   gets a hard 60%, .about-photos gets what's left, and neither one's
   content can ever reach into the other's track regardless of how the
   paper's own fluid width happens to land. */
.about-text {
  display: grid;
  gap: var(--space-05); /* same rhythm .prose itself uses everywhere else */
}

/* Below 64em (tablet AND mobile — deliberately grouped, not split at the
   48em tablet line): a single implicit column. .about-text and
   .about-photos are .prose's only two direct children, so they simply
   stack — text first, full width, then photos below it, centered
   (justify-self: center + margin-inline: auto) rather than bled to the far
   edge: with no text running beside it at this width, an edge-bled stack
   just reads as stranded space rather than composition, so it sits in the
   middle of the space the text leaves behind instead. The bleed-past-the-
   paper treatment is desktop-only now (below), where the two-column grid
   gives it real text to sit beside.

   The photo stack itself is a flex column, not absolute-positioned
   percentages — an earlier version pinned the second photo at a fixed 62%
   down the first one's own box, which only looked right for whatever aspect
   ratio the placeholder square happened to have. Flex just stacks each
   photo under the one before it at its own natural height, so this keeps
   working once real photos — square, portrait, whatever — replace
   whatever's there now. */
.about-photos {
  display: flex;
  flex-direction: column;
  gap: var(--space-03);
  justify-self: center;
  margin-block-start: var(--space-05);
  margin-inline: auto;
  inline-size: clamp(16rem, 30cqi, 22rem);
  z-index: 1; /* under .site-header (8) and the surface picker (9) */
}
@media (min-width: 64em) {
  /* The desktop split: text takes a flat 60%, photos get the remaining
     track. align-items: start keeps the photo column pinned near the top of
     the row instead of stretching to match all six paragraphs' combined
     height. column-gap is wider than the vertical rhythm gap (--space-05)
     used elsewhere in .prose — text and photos are two different ideas
     sitting side by side, not two paragraphs in a reading sequence, and
     wants more air between them than that implies.

     justify-self flips to end and the stack bleeds past the paper's own
     edge onto the mat — margin-inline-end cancels .container's own inline
     padding (--paper-margin-inline) and pushes further by --space-09. A
     negative margin overflows its grid track the same way a negative inset
     overflows a containing block — no clipping either way unless something
     upstream clips it, which nothing does at this width (the mobile-only
     `#main { overflow: clip }` a bit above only applies below 48em). */
  #about .prose {
    grid-template-columns: 60% 1fr;
    align-items: start;
    column-gap: var(--space-07);
  }
  .about-photos {
    justify-self: end;
    margin-block-start: 0; /* was the gap above a below-text block; beside the text, that gap isn't wanted */
    margin-inline-start: 0;
    margin-inline-end: calc(-1 * var(--paper-margin-inline) - var(--space-09));
  }
}

/* A fixed warm off-white, not a themed token: this represents a physical
   printed photo, which doesn't turn dark just because the page's own theme
   did. Padding is uneven on purpose — thicker at the block-end — the
   classic instant-print proportions that make it read as a photo and not
   just a card with a picture in it. --dx/--dy/--base-rot etc. and the
   transform composing them are the same pattern .tool-sticker uses above —
   see "Draggable objects" just above this block for why the formula lives
   here rather than on .draggable. */
.about-photo {
  background: #faf9f5;
  padding: var(--space-03) var(--space-03) var(--space-07);
  border-radius: var(--radius-sm);
  box-shadow: var(--shadow-paper);
  --dx: 0px;
  --dy: 0px;
  --base-rot: 0deg;
  --hover-rot: 0deg;
  --hover-scale: 1;
  transform: translate3d(var(--dx), var(--dy), 0)
    rotate(calc(var(--base-rot) + var(--hover-rot))) scale(var(--hover-scale));
}
.about-photo img {
  inline-size: 100%;
  block-size: auto;
}
.about-photo--one {
  --base-rot: 4deg;
  z-index: 1; /* sits in front of --two where the tilt overlaps slightly */
}
.about-photo--two {
  --base-rot: -5deg;
  margin-block-start: calc(-1 * var(--space-04)); /* slight tuck, not a full gap — reads as one stack, not two separate cards */
}
/* Below 48em the photos aren't draggable (js/main.js §9 skips makeDraggable
   there — a touch drag fights the page's own scroll gesture), so there's no
   way to pull them apart by hand any more. The tight tuck above reads as an
   invitation to do exactly that; without it, it's just two photos
   overlapping, so mobile gets its own plain gap instead. */
@media (max-width: 47.99em) {
  .about-photo--two {
    margin-block-start: var(--space-03); /* matches .about-photos' own flex gap, so the stack reads as evenly spaced */
  }
}

/* ------ Play hint (sticky-note--tip) ------
   One-time dismissible tip introducing the drag-anywhere feature and the
   mat color picker — js/main.js §10 builds this only if the visitor
   hasn't already dismissed it (a single localStorage flag), and only
   reveals it once an IntersectionObserver reports the sticker board has
   actually scrolled into view. Sits as a normal-flow sibling right before
   .sticker-board (not position: fixed, not appended to <body>) — a
   contextual note near the thing it explains, not a global floating toast.

   opacity/visibility are hidden unconditionally, NOT gated behind
   prefers-reduced-motion — unlike a decorative reveal, "hidden until the
   sticker board is reached" is this component's actual function. Only
   the fade/rise TRANSITION itself is motion-gated below, so a
   reduced-motion visitor still only sees the note appear at the right
   scroll position, just without the animated entrance. visibility (not
   opacity alone) also keeps the close button out of the tab order while
   hidden — a keyboard user tabbing through shouldn't be able to land on
   an invisible control. */
.sticky-note--tip {
  --base-rot: -1.5deg;
  display: flex;
  align-items: center;
  gap: var(--space-04);
  padding: var(--space-05) var(--space-06);
  margin-block-end: var(--space-06);
  max-inline-size: 32rem;
  opacity: 0;
  visibility: hidden;
  transform: rotate(var(--base-rot)) translateY(calc(-1 * var(--reveal-rise)));
  pointer-events: none;
}
.sticky-note--tip.is-visible {
  opacity: 1;
  visibility: visible;
  transform: rotate(var(--base-rot));
  pointer-events: auto;
}
@media (prefers-reduced-motion: no-preference) {
  .sticky-note--tip {
    transition: opacity var(--duration-reveal) var(--ease-entrance),
      transform var(--duration-reveal) var(--ease-entrance);
  }
}
.sticky-note--tip p {
  margin: 0;
  max-inline-size: none;
  font-size: var(--text-hand);
  line-height: var(--leading-dense);
}
.sticky-note--tip-close {
  appearance: none;
  flex-shrink: 0;
  inline-size: 2rem;
  block-size: 2rem;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  border: none;
  border-radius: var(--radius-sm);
  background: transparent;
  color: var(--color-note-text);
  cursor: pointer;
  transition: color var(--duration-fast) var(--ease);
}
@media (hover: hover) {
  .sticky-note--tip-close:hover {
    color: var(--color-accent);
  }
}
.sticky-note--tip-close:active {
  transform: scale(0.96);
}
@media (prefers-reduced-motion: reduce) {
  .sticky-note--tip-close:active {
    transform: none;
  }
}

/* ------ Footer ------ */
.site-footer {
  padding-block: var(--space-09);
}
.colophon {
  margin-block-start: var(--space-08);
  padding-block-start: var(--space-05);
  border-block-start: 1px solid var(--color-border);
  color: var(--color-text-secondary);
  font-size: var(--text-small);
  font-family: var(--font-mono);
  line-height: var(--leading-dense);
  max-inline-size: var(--measure);
}

/* ------ Case study pages ------ */
.case-hero h1 {
  font-size: var(--text-display);
  max-inline-size: 20ch;
}
.case-summary {
  margin-block-start: var(--space-06);
  font-size: var(--text-lead);
  font-weight: 400;
  color: var(--color-text-secondary);
}
.facts-bar {
  display: grid;
  gap: var(--space-05);
  margin-block: var(--space-08);
  padding-block: var(--space-06);
  border-block: 1px solid var(--color-border);
}
@container (min-width: 40em) {
  .facts-bar {
    /* 2 columns, not 4: 5 facts (Role/Team/Timeline/Status/Scope) into a
       4-up grid always wrapped the 5th into a half-empty row. Scope (always
       last in markup) spans both columns on its own closing row instead. */
    grid-template-columns: repeat(2, 1fr);
  }
  .fact:last-child {
    grid-column: 1 / -1;
  }
}
.fact dt {
  font-family: var(--font-mono);
  font-size: var(--text-mono);
  text-transform: uppercase;
  letter-spacing: 0.08em;
  color: var(--color-text-secondary);
}
[dir="rtl"] .fact dt {
  letter-spacing: 0;
}
.fact dd {
  margin: var(--space-02) 0 0;
  font-size: var(--text-small);
  font-weight: 500;
}

.prose {
  display: grid;
  gap: var(--space-05);
}
/* Every <p> caps at --measure (65ch) sitewide, but .prose itself has no
   width cap, so on a wide paper the reading column stops well short of the
   full container the hero above it just used edge to edge — an unused
   right-hand gap with nothing to justify it. Above 72em (comfortably past
   the 64em step where the paper reaches clamp(44rem,60vw,80rem), so the
   main column never gets squeezed below 65ch to make room), give that
   space to a margin column for .sticky-note--margin annotations (the NDA
   note, and any .case-quote a case study happens to have) — grid
   placement only, no `order`, so DOM/tab/reading order is untouched. */
@container (min-width: 72em) {
  .prose {
    grid-template-columns: minmax(0, var(--measure)) 1fr;
    column-gap: var(--space-07);
    align-items: start;
  }
  .prose > :not(.sticky-note--margin) {
    grid-column: 1;
  }
  .sticky-note--margin {
    grid-column: 2;
  }
}
.sticky-note--margin {
  --base-rot: -1.5deg;
  padding: var(--space-06);
}
.prose h2 {
  font-size: var(--text-h2);
  margin-block-start: var(--space-07);
}
/* h3 had no .prose-specific rule at all, so it only ever got the same 16px
   grid gap as a plain paragraph — a subsection heading (e.g. "Typography"
   under "Foundations") read no differently from a new sentence, which is
   what made that stretch of the case study look cramped. Between the base
   gap and h2's own step: a real, three-tier rhythm (paragraph 16px < h3
   subsection 24px < h2 section 48px total). */
.prose h3 {
  margin-block-start: var(--space-06);
}
/* Inline code is its own style, not a slice of the code-block palette:
   brand tint behind brand ink, so an identifier inside a sentence reads as a
   token rather than as a lifted card fragment. */
.prose code {
  font-family: var(--font-mono);
  font-size: 0.9em;
  background: var(--color-tint-brand);
  color: var(--color-accent);
  border-radius: var(--radius-sm);
  padding: 0.1em 0.4em;
  /* Inline code (an attribute name, a short value like aria-busy="true")
     reads as one token — never let it break mid-snippet onto a second
     line just because it lands near the paragraph's wrap point. */
  white-space: nowrap;
}

.prose figure {
  margin-block: var(--space-05);
}
.prose figure img {
  border-radius: var(--radius-md);
  border: 1px solid var(--color-border);
  outline: 1px solid var(--color-image-edge);
  outline-offset: -2px;
}
.prose figcaption {
  margin-block-start: var(--space-03);
  font-size: var(--text-mono);
  font-family: var(--font-mono);
  color: var(--color-text-secondary);
}

/* Two or three related figures shown in a single row (e.g. before/after,
   a comic strip, a set of certificates). minmax is kept narrow enough that
   three items still fit one line within --measure; auto-fit collapses to
   fewer columns on narrow viewports, no breakpoint needed. */
.figure-pair {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
  gap: var(--space-05);
  margin-block: var(--space-05);
}
.figure-pair figure {
  margin-block: 0;
}

/* A single figure shown smaller than the reading column (e.g. a template
   certificate that doesn't need full width to read) — centered, since a
   narrower figure left flush to the column's start reads as stranded
   rather than deliberately sized down. */
.figure-small {
  max-inline-size: 440px;
  margin-inline: auto;
}

/* ------ Embedded Figma prototype ------
   Same border/radius treatment as a .prose figure img, since a live embed
   sits in the same role a screenshot figure would. */
.figma-embed {
  display: block;
  width: 100%;
  border: 1px solid var(--color-border);
  border-radius: var(--radius-md);
  margin-block: var(--space-05);
}

/* ------ Case-study image lightbox ------
   js/main.js wraps every `.prose figure img` in a `.figure-zoom` button at
   runtime and builds one shared `.lightbox` <dialog>, appended once to
   <body>, that any of those buttons can open. This is additive: with
   JavaScript absent the images render exactly like any other figure
   above, inline at full size — .figure-zoom never exists, so none of
   this applies. */
.figure-zoom {
  appearance: none;
  display: block;
  inline-size: 100%;
  padding: 0;
  border: 0;
  background: none;
  cursor: zoom-in;
}
.figure-zoom img {
  transition: opacity var(--duration-fast) var(--ease);
}
/* :focus-visible stays ungated — a keyboard user needs this cue on every
   device, and it never latches the way :hover does after a tap. */
.figure-zoom:focus-visible img {
  opacity: 0.88;
}
@media (hover: hover) {
  .figure-zoom:hover img {
    opacity: 0.88;
  }
}

/* <dialog> ships its own UA styles (centered box, padding, border,
   white background) — all overridden here so it becomes a full-viewport
   scrim instead. The base rule applies regardless of the [open] state
   (harmless while closed, since the UA's `dialog:not([open])` rule still
   wins on `display`); centering only takes effect once [open] adds
   `display: flex` — an unconditional `display` here would out-specificity
   that UA rule and defeat `close()`. */
.lightbox {
  position: fixed;
  inset: 0;
  inline-size: 100%;
  block-size: 100%;
  max-inline-size: 100%;
  max-block-size: 100%;
  margin: 0;
  padding: var(--space-07);
  border: 0;
  background: var(--color-scrim);
  cursor: zoom-out;
}
.lightbox[open] {
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
}
.lightbox::backdrop {
  background: transparent;
}

.lightbox-figure {
  position: relative;
  margin: 0;
  max-inline-size: 100%;
  max-block-size: 100%;
  cursor: default;
}
.lightbox-image {
  display: block;
  max-inline-size: min(92vw, 100%);
  max-block-size: 80vh;
  inline-size: auto;
  block-size: auto;
  border-radius: var(--radius-md);
}
.lightbox-caption {
  margin-block-start: var(--space-04);
  color: var(--color-on-scrim);
  font-family: var(--font-mono);
  font-size: var(--text-mono);
  text-align: center;
}
.lightbox-caption:empty {
  display: none;
}

.lightbox-close {
  appearance: none;
  position: absolute;
  inset-block-start: var(--space-05);
  inset-inline-end: var(--space-05);
  inline-size: 2.75rem;
  block-size: 2.75rem;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  border-radius: 50%;
  border: 1px solid var(--color-on-scrim-muted-hover);
  background: var(--color-on-scrim-muted);
  color: var(--color-on-scrim);
  cursor: pointer;
  transition: background var(--duration-fast) var(--ease);
}
@media (hover: hover) {
  .lightbox-close:hover {
    background: var(--color-on-scrim-muted-hover);
  }
}

@media (prefers-reduced-motion: no-preference) {
  .lightbox[open] {
    animation: lightbox-scrim-in var(--duration-base) var(--ease);
  }
  .lightbox-figure {
    animation: lightbox-figure-in var(--duration-base) var(--ease-entrance);
  }
}
@keyframes lightbox-scrim-in {
  from { opacity: 0; }
  to { opacity: 1; }
}
@keyframes lightbox-figure-in {
  from { opacity: 0; transform: scale(0.96); }
  to { opacity: 1; transform: scale(1); }
}

/* ------ Tool info popover ------
   js/main.js §11 builds one shared `.tool-popover` <dialog>, appended
   once to <body>, that any .tool-sticker button opens (click, tap, or
   Enter/Space) with that tool's name/description/proficiency. Same
   open/close mechanics as the lightbox above (showModal, backdrop click,
   Escape, focus return) but a compact centered CARD rather than a
   full-bleed image scrim — a floating surface ON the scrim, not a viewer
   replacing the page, so it reuses --color-bg/--color-text (the site's
   own card tokens) instead of the lightbox's on-scrim ones. */
.tool-popover {
  position: fixed;
  inset: 0;
  inline-size: 100%;
  block-size: 100%;
  max-inline-size: 100%;
  max-block-size: 100%;
  margin: 0;
  padding: var(--space-06);
  border: 0;
  background: var(--color-scrim);
}
.tool-popover[open] {
  display: flex;
  align-items: center;
  justify-content: center;
}
.tool-popover::backdrop {
  background: transparent;
}

.tool-popover-card {
  position: relative;
  inline-size: 100%;
  max-inline-size: 24rem;
  padding: var(--space-07);
  border-radius: var(--radius-md);
  background: var(--color-bg);
  box-shadow: var(--shadow-float);
}

.tool-popover-icon {
  display: block;
  inline-size: 3.5rem;
  block-size: 3.5rem;
  margin-block-end: var(--space-05);
}

.tool-popover-name {
  font-size: var(--text-h3);
  margin-block-end: var(--space-03);
}

/* Reuses .tag-pill as-is (see "Concept tags" above) rather than a new
   badge component — same shape/fill contract every other badge on this
   site follows, already contrast-verified in both themes. */
.tool-popover-proficiency {
  margin-block-end: var(--space-04);
}

.tool-popover-description {
  color: var(--color-text-secondary);
  line-height: var(--leading-body);
}

.tool-popover-close {
  appearance: none;
  position: absolute;
  inset-block-start: var(--space-04);
  inset-inline-end: var(--space-04);
  inline-size: 2.5rem;
  block-size: 2.5rem;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  border: none;
  border-radius: var(--radius-sm);
  background: transparent;
  color: var(--color-text-secondary);
  cursor: pointer;
  transition: color var(--duration-fast) var(--ease), background var(--duration-fast) var(--ease);
}
@media (hover: hover) {
  .tool-popover-close:hover {
    color: var(--color-text);
    background: var(--color-bg-subtle);
  }
}
.tool-popover-close:active {
  transform: scale(0.96);
}
@media (prefers-reduced-motion: reduce) {
  .tool-popover-close:active {
    transform: none;
  }
}

@media (prefers-reduced-motion: no-preference) {
  .tool-popover[open] {
    animation: lightbox-scrim-in var(--duration-base) var(--ease);
  }
  .tool-popover-card {
    animation: lightbox-figure-in var(--duration-base) var(--ease-entrance);
  }
}

.case-nav {
  display: flex;
  justify-content: space-between;
  gap: var(--space-05);
  flex-wrap: wrap;
}

/* ------ NDA disclosure callout (case studies) ------
   Promoted to the .sticky-note--margin material (paper fill, shadow, tilt,
   handwriting font, defined below) so it reads as a deliberate, confident
   annotation rather than small print apologetically tucked above the first
   heading — following an NDA is a professionalism signal, and it should
   look like one. Every case study must carry this exact markup
   (`nda-note sticky-note sticky-note--margin`) — a plain-text NDA line
   next to a post-it one elsewhere reads as an inconsistency, not a choice.
   Sized up to --text-hand-lg (vs. the --text-hand every other sticky note
   uses) and freed from the sitewide `p { max-inline-size }` cap so it runs
   the full width of whichever column it sits in — the margin column above
   72em, the full prose column below it — then bleeds past the paper's own
   edge onto the mat by the same negative-margin technique the homepage
   highlight strip and About photos use, so the note visibly sticks out
   past the sheet rather than sitting neatly inside it. */
.nda-note {
  font-size: var(--text-hand-lg);
  line-height: var(--leading-dense);
  max-inline-size: none;
  margin-inline-end: calc(-1 * var(--paper-margin-inline) - var(--space-05));
}

/* ------ Placeholder note (unwritten narrative sections) ------
   No `font-style: italic` here. The site ships no italic face for Inter (see
   the @font-face note at the top of this file), so italic resolved to the
   browser's synthetic oblique — a mechanically slanted upright, which is
   exactly the artefact a portfolio built on self-hosted variable fonts
   should not be showing. The dashed border and the secondary colour already
   mark this as provisional. */
.draft-note {
  border: 1px dashed var(--color-border-strong);
  padding: var(--space-04) var(--space-05);
  border-radius: var(--radius-sm);
  font-size: var(--text-small);
  line-height: var(--leading-dense);
  color: var(--color-text-secondary);
  margin-block-start: var(--space-06);
}

/* ------ Case study: status callout (phase / adoption summary) ------
   Fill and radius come from the card contract; only padding here. */
.status-callout {
  padding: var(--space-06);
}
.status-callout-label {
  font-family: var(--font-mono);
  font-size: var(--text-mono);
  text-transform: uppercase;
  letter-spacing: 0.08em;
  color: var(--color-text-secondary);
  display: block;
  margin-block-end: var(--space-04);
}
[dir="rtl"] .status-callout-label {
  letter-spacing: 0;
}
.status-callout ul {
  list-style: none;
  padding: 0;
  display: grid;
  gap: var(--space-03);
}
.status-callout li {
  font-size: var(--text-small);
  line-height: var(--leading-dense);
  color: var(--color-text-secondary);
}
.status-callout li strong {
  color: var(--color-text);
}

/* ------ Case study: team list (used inside facts-bar dd) ------ */
.team-list {
  list-style: none;
  padding: 0;
  display: grid;
  gap: var(--space-01);
  font-size: var(--text-small);
  font-weight: 500;
}

/* ------ Case study: pull quote ------
   Also promoted to .sticky-note--margin — see the NDA note above for why.
   Sized to --text-hand-lg (26px), the same tier as .nda-note, so the two
   annotation types read as one consistent post-it voice rather than the
   quote looking like an afterthought next to the disclosure line. */
.case-quote p {
  font-size: var(--text-hand-lg);
  font-weight: 400;
  color: inherit;
  max-inline-size: none;
}

/* ------ Case study: property list (icon / token / button property
   definitions). Named .prop-list, not .spec-list — that name is already
   taken by the About page's specializations layout. ------ */
.prop-list {
  display: grid;
  gap: var(--space-03) var(--space-06);
  grid-template-columns: 1fr;
}
@container (min-width: 34em) {
  .prop-list {
    grid-template-columns: 11ch 1fr;
  }
}
.prop-list dt {
  font-family: var(--font-mono);
  font-size: var(--text-mono);
  color: var(--color-text-secondary);
}
.prop-list dd {
  margin: 0;
  font-size: var(--text-small);
  color: var(--color-text);
}

/* ------ Case study: detail list (bold lead-in bullets) ------ */
.detail-list {
  list-style: none;
  padding: 0;
  display: grid;
  gap: 0;
}
.detail-list li {
  padding-block: var(--space-04);
  border-block-end: 1px solid var(--color-border-soft);
  font-size: var(--text-small);
  line-height: var(--leading-dense);
  color: var(--color-text-secondary);
}
.detail-list li:first-child {
  border-block-start: 1px solid var(--color-border-soft);
}
.detail-list li strong {
  display: block;
  color: var(--color-text);
  font-weight: 600;
  margin-block-end: var(--space-01);
}

/* Case study summary block (Problem/Role/Impact/Stack): the first thing a
   skimming reader hits, right after the facts-bar, so it runs a size up
   from .detail-list's usual --text-small (14px) to --text-body (16px) —
   everything else (dividers, spacing, bold lead-in) stays shared. */
.case-summary-list li {
  font-size: var(--text-body);
}

/* ------ Case study: code block ------
   Code is inherently LTR (identifiers, punctuation) even inside an RTL
   article, so direction is pinned regardless of page direction. */
.code-caption {
  font-family: var(--font-mono);
  font-size: var(--text-mono);
  text-transform: uppercase;
  letter-spacing: 0.08em;
  color: var(--color-text-secondary);
  margin-block-end: var(--space-03);
}
[dir="rtl"] .code-caption {
  letter-spacing: 0;
}
.prose pre {
  background: var(--color-bg-subtle);
  border: 1px solid var(--color-border);
  border-radius: var(--radius-md);
  padding: var(--space-05);
  overflow-x: auto;
  direction: ltr;
  text-align: start;
}
.prose pre code {
  background: transparent;
  color: inherit;
  padding: 0;
  font-size: var(--text-mono);
  line-height: var(--leading-body);
  /* Block code needs its own line breaks back — .prose code's nowrap
     above is only for short inline snippets, not a multi-line listing. */
  white-space: pre;
}

/* ------ Case study: two-column comparison ------ */
.compare-grid {
  display: grid;
  gap: var(--space-05);
}
@container (min-width: 40em) {
  .compare-grid {
    grid-template-columns: repeat(2, 1fr);
  }
}
.compare-col {
  padding: var(--space-05);
  display: grid;
  gap: var(--space-03);
  align-content: start;
}
/* Weight, tracking, and the alt character-variant bundle already come from
   the base h1–h6 rule — this only overrides size and margin for the card
   context. */
.compare-col h4 {
  font-size: var(--text-body);
  margin: 0;
}
.compare-col p {
  font-size: var(--text-small);
  line-height: var(--leading-dense);
  color: var(--color-text-secondary);
}

/* ------ Case study: outcome stats ------ */
.case-stats {
  list-style: none;
  padding: 0;
  display: grid;
  gap: var(--space-05);
  grid-template-columns: 1fr;
}
@container (min-width: 34em) {
  .case-stats {
    grid-template-columns: repeat(2, 1fr);
  }
}
.case-stat {
  padding: var(--space-06);
  display: grid;
  gap: var(--space-02);
}
.case-stat-number {
  font-family: var(--font-heading);
  font-size: var(--text-display);
  font-weight: 600;
  line-height: var(--leading-tight);
  color: var(--color-text);
  font-variant-numeric: tabular-nums;
}
.case-stat-desc {
  font-size: var(--text-small);
  line-height: var(--leading-dense);
  color: var(--color-text-secondary);
  max-inline-size: none;
}

/* ------ Certification list ------ */
.cert-list {
  list-style: none;
  padding: 0;
  display: grid;
  gap: var(--space-01);
}
.cert-item {
  display: flex;
  flex-wrap: wrap;
  align-items: baseline;
  gap: var(--space-04);
  padding-block: var(--space-04);
  border-block-end: 1px solid var(--color-border-soft);
}
/* Below this width, name/meta/status wrapping mid-row (flex-wrap) reads
   as broken rather than responsive — stacked rows, each full width,
   instead. */
@media (max-width: 34.99em) {
  .cert-item {
    flex-direction: column;
    align-items: flex-start;
    gap: var(--space-02);
  }
}
.cert-name {
  font-weight: 500;
  flex: 1 1 16rem;
}
.cert-meta {
  font-family: var(--font-mono);
  font-size: var(--text-small);
  line-height: var(--leading-dense);
  color: var(--color-text-secondary);
}
/* ------ Certification: credential link ------
   Optional — present only on certs that have a real, checkable credential
   URL. Follows the same in-text link treatment as .prose a (accent colour,
   underline, gated hover) rather than the .btn or .tag-pill vocabulary:
   this is a reference to outside proof, not an action this page performs
   and not a status label. min-block-size holds the WCAG 2.2 AA 24px floor
   even though this is inline text, not a button — an inline link is still
   a target. */
.cert-link {
  display: inline-flex;
  align-items: center;
  min-block-size: 1.5rem;
  font-family: var(--font-mono);
  font-size: var(--text-mono);
  color: var(--color-accent);
  text-decoration: underline;
  text-underline-offset: 0.15em;
}
@media (hover: hover) {
  .cert-link:hover {
    color: var(--color-accent-hover);
  }
}
/* ------ Status chip (.cert-status) ------
   Takes the same shape and fill treatment as .tag-pill so every badge on the
   site is one component: solid tint, no outline, pill radius. The label
   itself is always the non-colour signal, so the tint only reinforces what
   the words already say. */
.cert-status {
  font-family: var(--font-mono);
  font-size: var(--text-mono);
  background: var(--color-surface-interactive);
  border-radius: var(--radius-pill);
  padding: var(--space-01) var(--space-03);
  color: var(--color-text-secondary);
  white-space: nowrap;
}
/* The state worth marking gets the accent tint; everything settled or not yet
   begun recedes into the neutral surface. */
.cert-status[data-status="ongoing"] {
  background: var(--color-tag-bg);
  color: var(--color-accent);
}

/* ------ Surface picker ------
   A floating control, independent of the header toggle row, for
   recoloring the cutting mat's grid. Lives outside <body> in reading order
   conceptually (it's chrome, not content) but is placed just before
   </body> in the DOM on every page so it never interrupts the primary
   reading/tab order — visitors reach it only after the page's real
   content. */
.surface-picker {
  position: fixed;
  inset-block-end: var(--space-05);
  inset-inline-end: var(--space-05);
  z-index: 9;
}
@media (min-width: 48em) {
  .surface-picker {
    inset-block-end: var(--space-06);
    inset-inline-end: var(--space-06);
  }
}

.surface-picker-trigger {
  appearance: none;
  inline-size: 2.75rem;
  block-size: 2.75rem;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  border-radius: 50%;
  border: 1px solid var(--color-border-strong);
  background: var(--color-bg);
  box-shadow: var(--shadow-float);
  cursor: pointer;
  transition: transform var(--duration-fast) var(--ease);
}
@media (hover: hover) {
  .surface-picker-trigger:hover {
    transform: scale(1.04);
  }
}
.surface-picker-trigger:active {
  transform: scale(0.96);
}
/* The trigger's only feedback is motion, so with motion off it needs a
   static cue instead. The :hover half still has to be pointer-gated — a
   reduced-motion preference says nothing about whether the device has a
   hover-capable pointer, and an ungated rule here would latch the accent
   border after a tap. :active never latches, so it stays ungated. */
@media (prefers-reduced-motion: reduce) {
  .surface-picker-trigger {
    transition: border-color var(--duration-fast) var(--ease);
  }
  .surface-picker-trigger:active {
    transform: none;
    border-color: var(--color-accent);
  }
  @media (hover: hover) {
    .surface-picker-trigger:hover {
      transform: none;
      border-color: var(--color-accent);
    }
  }
}
.surface-picker-trigger-swatch {
  inline-size: 1.25rem;
  block-size: 1.25rem;
  border-radius: 50%;
  background: var(--color-mat);
  border: 1px solid var(--color-paper-edge);
}

/* Discoverability nudge, not the accessible name — the trigger button's own
   aria-label already carries that, so this is aria-hidden and purely
   visual. Logical inset-inline-end mirrors to the correct side under
   [dir="rtl"] automatically, same as everything else on this site. */
.surface-picker-hint {
  position: absolute;
  inset-block-start: 50%;
  inset-inline-end: calc(100% + var(--space-03));
  transform: translateY(-50%);
  padding: var(--space-02) var(--space-03);
  border-radius: var(--radius-sm);
  background: var(--color-bg);
  border: 1px solid var(--color-border);
  box-shadow: var(--shadow-float);
  font-family: var(--font-mono);
  font-size: var(--text-mono);
  white-space: nowrap;
  opacity: 0;
  pointer-events: none;
}
[dir="rtl"] .surface-picker-hint {
  letter-spacing: 0;
}
@media (hover: hover) {
  .surface-picker-hint {
    transition: opacity var(--duration-fast) var(--ease);
  }
  .surface-picker:hover .surface-picker-hint {
    opacity: 1;
  }
}

.surface-picker-panel {
  position: absolute;
  inset-block-end: calc(100% + var(--space-03));
  inset-inline-end: 0;
  inline-size: max-content;
  padding: var(--space-04);
  border-radius: var(--radius-md);
  border: 1px solid var(--color-border);
  background: var(--color-bg);
  box-shadow: var(--shadow-float);
}
.surface-picker-panel[hidden] {
  display: none;
}

/* The panel used to appear and vanish instantly, with no connection to the
   button that opened it. A popover should scale out of its trigger rather
   than out of its own centre, so the origin is pinned to the corner the panel
   is anchored from — and mirrored for RTL, since `transform-origin` takes
   physical keywords and will not flip on its own.

   Entry uses @starting-style with `display` on an allow-discrete transition,
   so the animation runs off the existing `hidden` attribute with no extra
   class and no JS change. Browsers without @starting-style simply get the
   instant show/hide they get today.

   0.97, never 0: nothing in the real world appears from nothing, and a
   popover that grows from a point reads as a special effect rather than as a
   panel arriving. */
@media (prefers-reduced-motion: no-preference) {
  .surface-picker-panel {
    transform-origin: bottom right;
    transition: opacity var(--duration-fast) var(--ease-entrance),
      transform var(--duration-fast) var(--ease-entrance),
      display var(--duration-fast) allow-discrete;
  }
  [dir="rtl"] .surface-picker-panel {
    transform-origin: bottom left;
  }
  .surface-picker-panel[hidden] {
    opacity: 0;
    transform: scale(0.97);
  }
  @starting-style {
    .surface-picker-panel {
      opacity: 0;
      transform: scale(0.97);
    }
  }
}

.surface-picker-label {
  display: block;
  font-family: var(--font-mono);
  font-size: var(--text-mono);
  text-transform: uppercase;
  letter-spacing: 0.08em;
  color: var(--color-text-secondary);
  margin-block-end: var(--space-03);
}

/* The palette outgrew a single row once the original mat colours came back
   alongside the kami neutrals. Capped at six per row (6 x 2rem + 5 x 0.5rem)
   so it wraps to a tidy grid instead of running off the side of the panel on
   a narrow screen. */
.surface-picker-swatches {
  display: flex;
  flex-wrap: wrap;
  gap: var(--space-03);
  max-inline-size: 14.5rem;
  list-style: none;
  padding: 0;
  margin: 0;
}

/* Swatches set --swatch (a plain hex) as their fill; background-clip:
   content-box insets it inside the padding so the focus/checked ring
   (border-color, below) shows as a ring around the color dot. */
.surface-picker-swatch {
  appearance: none;
  inline-size: 2rem;
  block-size: 2rem;
  border-radius: 50%;
  border: 2px solid transparent;
  padding: 2px;
  cursor: pointer;
  background-color: var(--swatch, transparent);
  background-clip: content-box;
  transition: border-color var(--duration-fast) var(--ease);
}
.surface-picker-swatch[aria-checked="true"] {
  border-color: var(--color-accent);
}
@media (hover: hover) {
  .surface-picker-swatch:hover {
    border-color: var(--color-border-strong);
  }
  .surface-picker-swatch[aria-checked="true"]:hover {
    border-color: var(--color-accent);
  }
}

.surface-picker-custom {
  display: flex;
  align-items: center;
  gap: var(--space-03);
  margin-block-start: var(--space-04);
  padding-block-start: var(--space-04);
  border-block-start: 1px solid var(--color-border);
}
.surface-picker-custom input[type="color"] {
  inline-size: 2rem;
  block-size: 2rem;
  padding: 0;
  border: 2px solid var(--color-border-strong);
  border-radius: 50%;
  cursor: pointer;
  background: none;
}
/* Browsers render <input type="color"> with their own inner swatch box —
   strip its default padding/border chrome so our own ring is the only one
   visible. */
.surface-picker-custom input[type="color"]::-webkit-color-swatch-wrapper {
  padding: 0;
}
.surface-picker-custom input[type="color"]::-webkit-color-swatch {
  border: none;
  border-radius: 50%;
}
.surface-picker-custom input[type="color"]::-moz-color-swatch {
  border: none;
  border-radius: 50%;
}

.surface-picker-reset {
  font-family: var(--font-mono);
  font-size: var(--text-mono);
  color: var(--color-text-secondary);
  background: none;
  border: none;
  text-decoration: underline;
  text-underline-offset: 0.15em;
  cursor: pointer;
  min-block-size: 1.5rem;
  padding: 0;
}
@media (hover: hover) {
  .surface-picker-reset:hover {
    color: var(--color-accent);
  }
}

/* ------ Contact page ------ */
.contact-meta {
  color: var(--color-text-secondary);
  font-size: var(--text-small);
  line-height: var(--leading-dense);
  max-inline-size: var(--measure);
}
.contact-row {
  margin-block-start: var(--space-07);
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: var(--space-05);
  font-size: var(--text-h3);
  text-transform: uppercase;
  letter-spacing: 0.04em;
}
.contact-row a {
  color: var(--color-text);
  text-decoration: none;
}
@media (hover: hover) {
  .contact-row a:hover {
    color: var(--color-accent);
    text-decoration: underline;
    text-underline-offset: 0.15em;
  }
}
.contact-row .sep {
  color: var(--color-text-secondary);
}
[dir="rtl"] .contact-row {
  letter-spacing: 0;
}

/* ------ Reveal animation (JS adds .reveal; motion-safe only) ------
   Content easing onto the page, not a control responding to input — hence the
   slower duration and the decelerating entrance curve rather than --ease.
   --reveal-delay is set per element by main.js to stagger siblings. */
@media (prefers-reduced-motion: no-preference) {
  .reveal {
    opacity: 0;
    transform: translateY(var(--reveal-rise));
    transition: opacity var(--duration-reveal) var(--ease-entrance),
      transform var(--duration-reveal) var(--ease-entrance);
    transition-delay: var(--reveal-delay, 0ms);
  }
  .reveal.is-visible {
    opacity: 1;
    transform: none;
  }

  /* ------ Hero: the one orchestrated moment ------
     Above-the-fold content is already on screen at load, so there is no
     intersection to wait for. This is a CSS animation rather than the
     JS-driven transition above on purpose: the hero must never depend on a
     script running to become visible. */
  [data-reveal-intro] > * {
    animation: reveal-rise var(--duration-reveal) var(--ease-entrance) both;
  }
  [data-reveal-intro] > :nth-child(2) {
    animation-delay: calc(var(--reveal-stagger) * 1);
  }
  [data-reveal-intro] > :nth-child(3) {
    animation-delay: calc(var(--reveal-stagger) * 2);
  }
  [data-reveal-intro] > :nth-child(4) {
    animation-delay: calc(var(--reveal-stagger) * 3);
  }
  [data-reveal-intro] > :nth-child(n + 5) {
    animation-delay: calc(var(--reveal-stagger) * 4);
  }
}

@keyframes reveal-rise {
  from {
    opacity: 0;
    transform: translateY(var(--reveal-rise));
  }
  to {
    opacity: 1;
    transform: none;
  }
}
