:root {
  color-scheme: dark;
}

/* Registered as <number> so the browser can interpolate it on its own (plain
   custom properties don't transition). Used by the .skill-card and
   .about__photo hover effects as a multiplier on the tilt rotation: eases
   0 → 1 over the same 400ms as the hover scale lift, so the rotation ramps
   in from flat instead of snapping to its full value when the cursor enters
   near an edge. Cursor movement still updates --tilt-x/--tilt-y instantly
   inside that envelope. */
@property --tilt-amount {
  syntax: "<number>";
  inherits: true;
  initial-value: 0;
}

* {
  box-sizing: border-box;
}

/* Skip link (WCAG 2.4.1): off-screen until it receives keyboard focus, then it
   slides into the top-left corner. Sits above the header (z-index 30) so it's
   never covered; while a modal is open its container is `inert`, so it can't be
   focused and won't appear over the dialog. */
.skip-link {
  position: fixed;
  top: 8px;
  left: 8px;
  z-index: 1000;
  padding: 10px 16px;
  background: var(--color-bg);
  color: var(--color-fg);
  border: 1px solid var(--color-fg);
  border-radius: 8px;
  font-family: var(--font-mono);
  font-size: 14px;
  text-decoration: none;
  transform: translateY(calc(-100% - 16px));
  opacity: 0;
  transition: transform 120ms ease, opacity 120ms ease;
}
.skip-link:focus,
.skip-link:focus-visible {
  transform: translateY(0);
  opacity: 1;
}
/* The skip target is a large structural container, not a control — suppress the
   focus ring that would otherwise outline the whole viewport when focus lands
   there via the skip link. */
#screens:focus {
  outline: none;
}

html,
body {
  margin: 0;
  padding: 0;
  height: 100%;
  overflow: hidden;
}

body {
  background: var(--color-bg);
  font-family: var(--font-sans);
  color: var(--color-fg);
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
  /* Figma arrow REPLACES the OS default arrow — the branded cursor only
     shows where the OS would show its default arrow. The rules below
     restore the OS pointer on interactive elements and the OS text I-beam
     on selectable text, so hover/text affordances still read correctly.
     Hotspot (2, 0) lines up with the arrow tip in the 28×28 SVG. */
  cursor: url("/assets/icons/figmacursor.svg") 2 0, auto;
}

/* OS pointer on interactive elements — overrides the inherited Figma. */
a,
button {
  cursor: pointer;
}

/* OS text I-beam on selectable text. <a>/<button> inside a <p> still win
   here because their own explicit cursor rule beats inheritance. Skill
   cards override below for the .skill-cursor pip on desktop fine pointers. */
p,
h1,
h2,
h3,
h4,
h5,
h6,
li {
  cursor: text;
}

::selection {
  background: var(--color-selection-bg);
  color: var(--color-selection-fg);
}

/* ---------------------------------------------------------------------------
   Header
   --------------------------------------------------------------------------- */

.site-header {
  position: fixed;
  top: 0;
  left: 0;
  right: 0;
  /* Above .project-page (z-index 20) so the header stays visible while
     reading a project. The contact modal (z-index 100) still wins. */
  z-index: 30;
  display: grid;
  /* Persistent across route changes — tag for the View Transition so the
     header doesn't crossfade with the rest of the page. */
  view-transition-name: site-header;
  /* auto | 1fr | auto so the logo and CTA hug the edges and the nav (when
     visible) absorbs the middle slack. With 1fr in the middle (vs the original
     1fr auto 1fr), narrow viewports can't squeeze the side fr-columns and
     overflow the CTA over the logo. */
  grid-template-columns: auto 1fr auto;
  align-items: center;
  padding: var(--header-pad-top) var(--page-gutter) 0;
  pointer-events: none;
}

/* Eased scrim behind the header so content scrolling under it fades out
   smoothly instead of running straight into the logo/nav. Curve is
   1 − smoothstep(t) = 1 − (3t² − 2t³) over the top 160px — symmetric,
   with zero slope at both endpoints. No steep tail means no kink at the
   bottom, so even-spaced stops (every 8px) keep adjacent-stop alpha gaps
   ≤8% across the whole span — well within tolerance against the dark bg. */
.site-header::before {
  content: "";
  position: absolute;
  top: 0;
  left: 0;
  right: 0;
  height: 160px;
  z-index: -1;
  background: linear-gradient(
    to bottom,
    color-mix(in srgb, var(--color-bg), transparent 0%) 0,
    color-mix(in srgb, var(--color-bg), transparent 1%) 8px,
    color-mix(in srgb, var(--color-bg), transparent 3%) 16px,
    color-mix(in srgb, var(--color-bg), transparent 6%) 24px,
    color-mix(in srgb, var(--color-bg), transparent 10%) 32px,
    color-mix(in srgb, var(--color-bg), transparent 16%) 40px,
    color-mix(in srgb, var(--color-bg), transparent 22%) 48px,
    color-mix(in srgb, var(--color-bg), transparent 28%) 56px,
    color-mix(in srgb, var(--color-bg), transparent 35%) 64px,
    color-mix(in srgb, var(--color-bg), transparent 43%) 72px,
    color-mix(in srgb, var(--color-bg), transparent 50%) 80px,
    color-mix(in srgb, var(--color-bg), transparent 57%) 88px,
    color-mix(in srgb, var(--color-bg), transparent 65%) 96px,
    color-mix(in srgb, var(--color-bg), transparent 72%) 104px,
    color-mix(in srgb, var(--color-bg), transparent 78%) 112px,
    color-mix(in srgb, var(--color-bg), transparent 84%) 120px,
    color-mix(in srgb, var(--color-bg), transparent 90%) 128px,
    color-mix(in srgb, var(--color-bg), transparent 94%) 136px,
    color-mix(in srgb, var(--color-bg), transparent 97%) 144px,
    color-mix(in srgb, var(--color-bg), transparent 99%) 152px,
    color-mix(in srgb, var(--color-bg), transparent 100%) 160px
  );
}

.site-header > * {
  pointer-events: auto;
}

.site-header__logo {
  justify-self: start;
  display: inline-block;
  font-family: var(--font-mono);
  font-weight: var(--fw-extrabold);
  font-size: 24px;
  letter-spacing: 0.04em;
  color: var(--color-fg);
  text-decoration: none;
  white-space: nowrap;
}

/* Hover reveal: jake.ly → jake.blakeley. The "blake" and the "e" before "y"
   slide in via grid track sizing (0fr → 1fr), which lets the box animate to
   its natural content width without hard-coding pixel/ch values. The two
   grow regions are staggered so the reveal reads left-to-right on hover-in
   and reverses (right-to-left) on hover-out — see transition-delay block. */
.site-header__logo-grow {
  display: inline-grid;
  grid-template-columns: 0fr;
  vertical-align: baseline;
  transition: grid-template-columns 320ms cubic-bezier(0.65, 0, 0.35, 1);
}

.site-header__logo-grow > span {
  min-width: 0;
  overflow: hidden;
  white-space: nowrap;
}

.site-header__logo:hover .site-header__logo-grow,
.site-header__logo:focus-visible .site-header__logo-grow {
  grid-template-columns: 1fr;
}

.site-header__logo-dot {
  display: inline-block;
  transition: opacity 320ms cubic-bezier(0.65, 0, 0.35, 1),
    transform 320ms cubic-bezier(0.65, 0, 0.35, 1);
}

.site-header__logo:hover .site-header__logo-dot,
.site-header__logo:focus-visible .site-header__logo-dot {
  opacity: 0;
  transform: translateX(0.5em);
}

/* Asymmetric delays so the reveal feels like it ripples outward from the dot.
   Resting state (no hover) carries the *collapse* delays — blake collapses
   after e, mirroring the expand order in reverse. */
.site-header__logo-grow--blake {
  transition-delay: 120ms;
}
.site-header__logo-grow--e {
  transition-delay: 0ms;
}
.site-header__logo:hover .site-header__logo-grow--blake,
.site-header__logo:focus-visible .site-header__logo-grow--blake {
  transition-delay: 0ms;
}
.site-header__logo:hover .site-header__logo-grow--e,
.site-header__logo:focus-visible .site-header__logo-grow--e {
  transition-delay: 120ms;
}

@media (prefers-reduced-motion: reduce) {
  .site-header__logo-grow,
  .site-header__logo-dot {
    transition-duration: 0ms;
  }
}

.site-header__nav {
  display: none;
  gap: 16px;
  justify-self: center;
  align-items: center;
}

.site-header__nav .nav-link {
  display: inline-flex;
  align-items: center;
  padding: 12px 16px;
  color: var(--color-secondary);
  text-decoration: none;
  text-transform: uppercase;
  border-radius: 9999px;
  transition: color 120ms linear;
}

.site-header__nav .nav-link:hover,
.site-header__nav .nav-link:focus-visible {
  color: var(--color-fg);
}

/* Chained selector — .experiment-button base rules appear later in this file
   and tie on specificity, so unscoped .site-header__cta loses on source
   order. Chaining both classes bumps specificity (two classes) so these
   header-specific overrides reliably win. */
.site-header__cta.experiment-button {
  justify-self: end;
  --h: 48px;
  /* Placeholder pill shown during early loading. The CTA is above the fold and
     its final state is a solid light shader pill, but the shader canvas is
     blank until button.js warms up the WebGL context — until then the base
     .experiment-button is `background: transparent`, so the button reads as
     bare dark text with no pill and pops when the shader paints. Painting a
     flat light pill here (the same fill as the --no-gl solid fallback) keeps it
     close to its final shape/color from first paint; the canvas (z-index 0,
     opaque, +16px bleed) covers this once it draws. Two-class specificity beats
     the base transparent rule. */
  background: #f0f0f2;
  border-radius: 9999px;
  /* Bleed sized to hold the exterior chromatic glow (which reaches well out to
     the left/right) without clipping it at this smaller size — the falloffs
     scale off canvas height, so a bigger canvas keeps the effect readable.
     This only enlarges the absolutely-positioned, pointer-events:none WebGL
     canvas symmetrically around the button (it's purely the halo's bleed
     area), so growing it costs no layout/hit-area. Capped at the page gutter
     (24px here) so the canvas can't extend past the viewport edge and trigger
     horizontal scroll. */
  --bleed: 24px;
  padding: 0 16px;
  font-size: 14px;
}

/* Match the JAKE.LY logo weight. Specificity bump: .experiment-button later
   in this file sets font-weight on the button itself, so target the label
   directly with a chained selector to win the cascade. */
.site-header__cta.experiment-button .experiment-button__label {
  font-weight: var(--fw-extrabold);
}

@media (min-width: 768px) {
  /* Symmetric side columns center the nav to the viewport instead of to
     the slack between the (different-width) logo and CTA. Safe at this
     breakpoint because there's enough room for both fr-columns to hold
     their content without overlapping. */
  .site-header {
    grid-template-columns: 1fr auto 1fr;
  }
  .site-header__nav {
    display: inline-flex;
  }
  .site-header__cta.experiment-button {
    --h: 56px;
    /* Roomier bleed for the exterior glow's wide left/right reach. Held just
       under the 64px page gutter at this breakpoint (8px margin) so the canvas
       can't extend past the viewport edge and trigger horizontal scroll. */
    --bleed: 56px;
    padding: 0 28px;
    font-size: 20px;
  }
}

/* ---------------------------------------------------------------------------
   Snap container + screens
   --------------------------------------------------------------------------- */

.screens {
  height: 100vh;
  height: 100dvh;
  overflow-y: scroll;
  /* Never scroll horizontally: a section whose content runs wider than the
     viewport (e.g. the 3D-photos model canvas) would otherwise promote the
     default overflow-x:visible to auto and let the whole page scroll sideways,
     which also throws off the pointer→viewport mapping used for tap-to-move. */
  overflow-x: clip;
  scroll-padding-top: var(--header-h);
  scrollbar-width: none;
  /* No CSS scroll-behavior: screens.js owns every programmatic scroll
     (nav clicks, magnet snap, keyboard nav) and runs its own rAF
     animation. Layering browser smooth-scroll on top of rAF scrollTop
     writes makes the motion feel like it's easing in. */
}

/* Initial state for the work (Orion) section on first paint when the page
   loads at home — screens.js animates from this state up into its scroll-
   driven opacity. Without this rule the section paints at full opacity for
   the frame or two before the first opacity update runs. The inline-head
   script gates the class, so deep-linking to any other section skips the
   entrance entirely. */
html.home-entrance #work {
  opacity: 0;
  transform: translateY(128px);
}

.screens::-webkit-scrollbar {
  display: none;
}

.screen {
  position: relative;
  /* Content-height sections: top/bottom breathing room is 64px mobile, 128px
     desktop (overridden below). The fixed header overlays the page, and the
     snap target in screens.js subtracts --header-h so each section's top
     lands flush against the header bottom — the full 64/128 padding then
     sits between the header bottom and the section's content. */
  padding: 64px var(--page-gutter);
  display: flex;
  flex-direction: column;
  align-items: center;
}

@media (min-width: 768px) {
  .screen {
    padding: 128px var(--page-gutter);
  }
}

/* Centered max-width column shared by every non-home screen.
   The screen itself just provides the snap target + viewport padding.
   container-type: inline-size lets project titles size off the actual
   inner-column width via cqi units, so they fill the column at every
   viewport without per-breakpoint clamps. */
.screen__inner {
  width: 100%;
  max-width: var(--content-max);
  container-type: inline-size;
}

/* ---------------------------------------------------------------------------
   Home
   --------------------------------------------------------------------------- */

.screen--home {
  /* Home sizes to 85% of the viewport so the top of the next section (work)
     peeks under it as a scroll affordance. Re-add dvh sizing and vertical
     centering here (the base .screen rule is content-height) — the headline
     centers in the 85dvh box and the scroll hint pins to its bottom, which
     places the arrow ~15% above the viewport bottom. Symmetric vertical
     padding (page-gutter) keeps the headline geometrically centered. */
  min-height: 85vh;
  min-height: 85dvh;
  justify-content: center;
  padding-top: var(--page-gutter);
  padding-bottom: var(--page-gutter);
}

/* The link is a static, oversized hit target so the cursor doesn't lose
   the element when the arrow translates on hover. The arrow itself lives
   inside as a child that handles the visual motion. */
.screen__scroll-hint {
  position: absolute;
  bottom: 0;
  left: 50%;
  transform: translateX(-50%);
  /* Hit target sized to fully contain the arrow's full 64px travel range
     (32px arrow height + 64px translate room + padding). */
  width: 96px;
  height: 128px;
  color: var(--color-secondary);
  display: inline-flex;
  align-items: flex-end;
  justify-content: center;
  transition: color 600ms ease-out;
}

.screen__scroll-hint:hover,
.screen__scroll-hint:focus-visible {
  color: var(--color-fg);
}

/* Arrow visual: lifted 64px above resting by default; on parent hover it
   translates back down 64px (to its resting position) and scales up.
   Bob runs on an inner SVG wrapper so the parent's hover transform doesn't
   fight the keyframe animation. */
.screen__scroll-hint-arrow {
  display: inline-block;
  width: 24px;
  height: 32px;
  transform: translateY(-24px);
  transition: transform 600ms ease-out;
}

.screen__scroll-hint:hover .screen__scroll-hint-arrow,
.screen__scroll-hint:focus-visible .screen__scroll-hint-arrow {
  transform: translateY(0) scale(1.4);
}

.screen__scroll-hint-arrow svg {
  width: 100%;
  height: 100%;
  animation: scroll-hint-bob 1.8s ease-in-out infinite;
}

@keyframes scroll-hint-bob {
  0%, 100% { transform: translateY(0); }
  50%      { transform: translateY(8px); }
}

/* ---------------------------------------------------------------------------
   Project screen — 12-column grid inside the centered 1048px column.
   Each project applies its own column spans + z-index so the layouts read
   distinctly while sharing the same grid. .project__media is layered behind
   text via z-index so titles can sit on top of the image.
   --------------------------------------------------------------------------- */

.project {
  display: grid;
  grid-template-columns: repeat(12, 1fr);
  column-gap: 24px;
  row-gap: 32px;
  align-items: start;
  position: relative;
}

.project__title {
  grid-column: 1 / -1;
  position: relative;
  z-index: 2;
  width: 100%;
  text-align: left;
  font-family: var(--font-sans);
  font-weight: var(--fw-semibold);
  font-size: clamp(56px, 11vw, 144px);
  line-height: 1;
  letter-spacing: -0.02em;
  color: var(--color-fg);
  margin: 0;
}

.project__media {
  grid-column: 1 / -1;
  position: relative;
  z-index: 1;
  width: 100%;
  display: flex;
  align-items: center;
  justify-content: center;
  min-height: 0;
}

.project__media img {
  display: block;
  /* Mobile: cap at 80vw so tall portraits don't blow past the screen height
     (max-height alone isn't enough when the natural aspect ratio is tall and
     narrow — the image still wants to be column-wide). Desktop overrides
     below to allow full column width. */
  max-width: 80vw;
  max-height: 56vh;
  width: auto;
  height: auto;
  object-fit: contain;
}

.project__meta {
  grid-column: 1 / -1;
  position: relative;
  z-index: 2;
  display: flex;
  flex-direction: column;
  align-items: flex-start;
  gap: 24px;
  /* Mobile: pull meta up so the description sits ~16px below the title
     (default row-gap 32 - 16 = 16). Desktop overrides this per-project. */
  margin-top: -16px;
}

.project__subtitle {
  margin: 0;
  text-align: left;
}

/* ---- Mobile defaults ---- */
/* Default mobile stacking order: image, title, meta. The DOM is
   media → title → meta, which matches this order. Per-project mobile rules
   override below. */

/* Orion: title fills the column at every viewport via container query units.
   Mobile breaks into two centered lines via <br>; "Orion AR" is the longer
   line, so size off it. Desktop hides the <br> and renders one line. */
.project--orion .project__title {
  font-size: 22.5cqi;
  text-align: center;
}
.project--orion .project__meta {
  align-items: center;
  text-align: center;
}
.project--orion .project__subtitle {
  text-align: center;
}
@media (min-width: 768px) {
  .project--orion .project__title {
    font-size: 11.5cqi;
    white-space: nowrap;
    text-align: left;
  }
  .project--orion .project__title br { display: none; }
}

/* GenUI: title fills the column at every viewport. 13.5cqi × 1048 ≈ 141px;
   scales linearly down with the inner-column width. */
.project--genui .project__title {
  font-size: 13.5cqi;
  white-space: nowrap;
}
/* Force the subtitle to break to at least 2 lines on every viewport. */
.project--genui .project__subtitle {
  max-width: 22ch;
}

/* Display Glasses: always two lines via <br>. "Display Glasses" is the
   longer line, so size off it. Mobile: placeholder stays in default flow
   above text; title fills column. Desktop: smaller cqi because title only
   spans cols 1-9 and the placeholder is pulled out to its own absolute box. */
.project--displayglasses .project__title {
  font-size: 11.5cqi;
  /* Pin the title to its own compositing layer so it ALWAYS paints above the
     glasses backdrop. The placeholder beneath it is GPU-composited (scroll
     transform/will-change in this file, the inline `translate` parallax gazer.js
     writes, and `.gazer`'s mix-blend-mode in gazer.css). The title is z-index 2
     but `isolation: isolate` only makes a stacking context, not a GPU layer —
     so on iOS/WebKit the composited placeholder transiently punches THROUGH the
     non-composited title (the backdrop "jumps ahead then settles behind"). A
     translateZ(0) promotes the title to a real layer too, so the compositor
     orders both by z-index and the backdrop can never paint over the text.
     Applied at all breakpoints (harmless where the layouts don't overlap).
     Revert: drop this transform. */
  transform: translateZ(0);
}
@media (min-width: 768px) {
  .project--displayglasses .project__title {
    font-size: 9.5cqi;
  }
}

/* Subtitle "Developer platform for Meta Ray-Ban Displays" is held to exactly
   two balanced lines at every width. ~28ch caps the line length so it never
   collapses to one line on wide viewports, and is wide enough that the long
   first line ("Developer platform for") fits without spilling a third line on
   phones — it was 22ch before, which the uppercase caps-tracking pushed over
   into three lines. text-wrap: balance evens the two lines. One value for all
   breakpoints (desktop used the same 28ch), so no per-breakpoint override. */
.project--displayglasses .project__subtitle {
  max-width: 28ch;
  text-wrap: balance;
}

/* Responsive square placeholder. Mobile: cap at 65vw so the square stays
   shorter than the viewport, with a hard 280px ceiling so it doesn't get
   visually heavy on the wider end of mobile. Desktop: sits inside the
   50%-width absolute .project__media; max-height keeps short viewports
   from stretching the square past the section. aspect-ratio ties height
   to width either way. */
.project--displayglasses .project__placeholder {
  position: relative;
  width: 100%;
  max-width: min(65vw, 280px);
  aspect-ratio: 1 / 1;
  margin-inline: auto;
  /* Defaults are the settled state so the section reads correctly under
     prefers-reduced-motion (screens.js' coverage loop short-circuits there)
     and during the cold-load paint before the RAF kicks. screens.js drives
     these vars while scrolling. */
  --glasses-scale: 1;
  --glasses-translate-y: 0px;
  --glasses-square-opacity: 1;
  transform: translateY(var(--glasses-translate-y)) scale(var(--glasses-scale));
  transform-origin: center;
  will-change: transform;
}
@media (min-width: 768px) {
  .project--displayglasses .project__placeholder {
    max-width: 400px;
    max-height: 64vh;
  }
}

/* Glasses backdrop. Reference geometry in metaraybandisplay.png: a 480×480
   lens square at offset (1991, 260) inside a 3091×1575 image. Express the
   backdrop's size + offset as percentages of the placeholder (the 480-side
   reference). Top/height percentages use the placeholder's height — equal
   to its width because aspect-ratio: 1/1 — so x and y share the same base.
   Paints first (tree order → behind the ::after overlay). The PNG/WebP is
   transparent around the glasses, so the frame already melts into the page
   bg with no hard edge — no top fade needed (and a dark fade would only dim
   the brow). */
.project--displayglasses .project__placeholder::before {
  content: "";
  position: absolute;
  left:   calc(-1991 / 480 * 100%);
  top:    calc(-260  / 480 * 100%);
  width:  calc( 3091 / 480 * 100%);
  height: calc( 1575 / 480 * 100%);
  background: url("/assets/images/metaraybandisplay.webp") no-repeat center / 100% 100%;
  pointer-events: none;
}

/* 10% white overlay — fills the placeholder, paints on top of the backdrop
   so the right-lens area is dimmed but the surrounding frame reads at full
   strength. Tree order alone puts ::after above ::before. */
.project--displayglasses .project__placeholder::after {
  content: "";
  position: absolute;
  inset: 0;
  background: rgba(255, 255, 255, 0.1);
  opacity: var(--glasses-square-opacity);
  pointer-events: none;
}

/* Clip the backdrop overflow so the wide glasses image doesn't push the
   page horizontally (or vertically). Section keeps its flex layout. */
.screen--project:has(.project--displayglasses) {
  overflow: hidden;
}

/* Tablet/desktop layout: contain the backdrop's UPWARD overhang. The ::before
   backdrop extends ~0.54× the placeholder's height above the placeholder, and
   the section's overflow:hidden clips at the section's top edge. The clip lands
   at `overhangOffset + padding-top` from the section top; with the base 128px
   padding that sum goes NEGATIVE on short/wide viewports (landscape iPad, or
   portrait once Safari's toolbar shrinks the height), so the clip edge rises
   into the opaque glasses brow and slices it — the "cut off at the top" bug.
   Bumping the section's top padding to ~232px keeps the clip edge up in the
   image's TRANSPARENT zone above the brow at every height down to ~600px tall
   (below that the placeholder — capped by max-height:64vh — shrinks and the
   overhang shrinks with it, so the margin only grows). On tall viewports the
   centered snap (screens.js targetForSection) scrolls this extra top padding
   off-screen, so the centered look is unchanged; on short viewports it reads as
   buffer between the header and the glasses. Mobile (<768px) has its own
   overhang-aware padding calc below, so this is scoped to the desktop layout. */
@media (min-width: 768px) {
  .screen--project:has(.project--displayglasses) {
    padding-top: 232px;
  }
}

/* Fade the bottom 10% of the section into the page background. The glasses
   backdrop scales taller than the section at many aspect ratios, so the
   overflow clip alone leaves a hard horizontal edge where the image cuts.
   This pseudo paints over that edge with a transparent→bg gradient, and is
   a no-op anywhere the backdrop isn't already painting (page-bg over page-bg).
   z-index sits above .project__media (z-index: 0) but below .project__title
   and .project__meta (both z-index: 2) so the text stays crisp. */
.screen--project:has(.project--displayglasses)::after {
  content: "";
  position: absolute;
  left: 0;
  right: 0;
  bottom: 0;
  height: 10%;
  background: linear-gradient(to bottom, transparent, var(--color-bg));
  pointer-events: none;
  z-index: 1;
}


/* Mobile: the backdrop image extends 260/480 ≈ 54% of the placeholder's height
   above the placeholder's top (per the ::before geometry above) — call that
   one "overhang unit". --ph-shift is the design value for how much higher
   we want the placeholder to sit than a strict no-clip baseline would put
   it. The section's top padding = 2 × overhang units - half the shift
   (partial compensation), so the backdrop has well over the room it needs
   to fit inside the section (lens stays aligned, no clip). scroll-margin-top
   then claws back the full shift so the snap lands tight to the header
   instead of low in the viewport — magnet-snap reads this via screens.js'
   targetForSection. Title and meta flow naturally below the placeholder
   with the default row-gap; subtitle max-width forces a break after "for"
   so it sits on two lines. Placeholder left-aligns to the column edge
   (override the base margin-inline: auto with 0 and flip the media's
   default centered flex justification to flex-start). */
@media (max-width: 767.98px) {
  .screen--project:has(.project--displayglasses) {
    --ph-shift: 64px;
    padding-top: calc(min(65vw, 280px) * 520 / 480 - var(--ph-shift) / 2);
    scroll-margin-top: calc(var(--ph-shift) * -1);
    /* The ::before backdrop overhangs ~half a placeholder above the
       placeholder's top edge, which pulls the perceived center of the
       section above its geometric center. Bias the centered snap (see
       screens.js targetForSection) downward by --ph-shift so the lens
       reads as visually centered rather than top-heavy. Reused from the
       existing design knob — no separate magic number. */
    --snap-offset-y: var(--ph-shift);
  }
  .project--displayglasses .project__media {
    justify-content: flex-start;
  }
  .project--displayglasses .project__placeholder {
    margin-inline: 0;
    /* Drop the WHOLE scroll-driven transform on mobile — both the 1.25→1
       scale() (forces a per-frame WebKit/iOS re-raster of the large backdrop
       WebP) and the translateY() slide (a main-thread positional follow that
       can't stay in lockstep with iOS's off-main-thread scroll, so the frame
       jitters against the page). Neither reads as much at phone size anyway.
       screens.js still writes --glasses-scale / --glasses-translate-y, but
       nothing references them here, so the placeholder just sits at its layout
       position. will-change: auto drops the now-pointless layer promotion to
       reclaim GPU memory. Keyed to the existing 767.98px breakpoint, so it
       re-evaluates on resize / orientation change for free. Revert: restore
       `transform: translateY(var(--glasses-translate-y)) scale(var(--glasses-scale))`
       and `will-change: transform`. */
    transform: none;
    will-change: auto;
  }
}

/* Same kill-switch for iOS at ANY width. iPadOS reports a desktop-width
   viewport (and masquerades as macOS), so it slips past the mobile breakpoint
   above — yet it's WebKit/iOS and hits the identical off-main-thread-scroll
   jank from the scroll-driven scale()/translateY(). html.is-ios is set
   synchronously in index.html's head. The selector's higher specificity
   (0,3,0) wins over the base placeholder rule regardless of source order.
   Revert: remove this rule and the is-ios head script. */
html.is-ios .project--displayglasses .project__placeholder {
  transform: none;
  will-change: auto;
}

/* 3D Photos: title one line full width. "3D Photos" is short → high cqi
   multiplier so it stretches across the column. Mobile reorders to
   title-over-image: title in row 1, image in row 2 pulled up so its top
   sits 64px under the title baseline (overlapping into the title region).
   row-gap is 32, so margin-top -(32 + 64) = -96px lifts the image into
   the overlap. Title z-index 2 keeps it above the image. */
.project--photos3d .project__title {
  font-size: 19cqi;
  white-space: nowrap;
}
.project--photos3d .project__meta {
  align-items: center;
  text-align: center;
}
/* Mobile-only: title-over-image overlap. Scoped via max-width so the
   desktop block (position: absolute media, full-width title) doesn't have
   to fight grid-row / margin-top declarations from this rule. */
@media (max-width: 767.98px) {
  .project--photos3d .project__title {
    grid-row: 1;
  }
  .project--photos3d .project__media {
    grid-row: 2;
    margin-top: -96px;
  }
  /* Portrait-tall image — cap so the screen doesn't overflow vertically. */
  .project--photos3d .project__media img {
    max-width: 65vw;
  }
  .project--photos3d .project__meta {
    grid-row: 3;
  }
}
.project--photos3d .project__subtitle {
  text-align: center;
  /* Browser-balanced wrap so the two lines of "Adding depth to Facebook's
     news feed" land at roughly equal length instead of one long + one short. */
  text-wrap: balance;
}

/* ---- 3D model canvases (photos3d-model.js) ----
   Two stacked, pixel-aligned canvases straddle the title: the bottom half
   renders below it (z-index 0), the top half above it (z-index 3 > the title's
   2) — so the "3D Photos" text is sandwiched inside the model. The canvases are
   parented to .project (siblings of the title), NOT to .project__media: because
   .project has `container-type: inline-size`, it forms a stacking context, and
   anything nested under .project__media would have its z-index trapped beneath
   the title. As siblings of the title in that same context, the canvases'
   z-index straddles it correctly. Their left/top/width/height are written by
   photos3d-model.js to overlay the media box. pointer-events:none keeps clicks
   falling through to the title/button beneath. */
.project--photos3d .photos3d-model {
  position: absolute;
  pointer-events: none;
}
.project--photos3d .photos3d-model--bottom {
  z-index: 0;
}
.project--photos3d .photos3d-model--top {
  z-index: 3;
}
/* The fallback <img> is HIDDEN by default so it never flashes before the model
   draws — it still occupies layout (opacity, not display) so it gives the mobile
   media box its height (the box has no explicit size there). photos3d-model.js
   reveals it (adds .is-model-failed) only when the 3D genuinely can't load: no
   WebGL, or a GLB fails to fetch/parse. Mirrors the Orion fallback below. */
.project--photos3d .project__media img {
  opacity: 0;
  transition: opacity 300ms ease;
}
.project--photos3d .project__media.is-model-failed img {
  opacity: 1;
}

/* ---- Orion 3D model canvas (orion-model.js) ----
   A single canvas that sits IN FRONT OF the title (z-index 3 > the title's 2),
   so the glasses occlude the "Orion AR Glasses" text where they overlap — the
   text reads as sitting behind the model. The subtitle + button (.project__meta)
   are lifted to z-index 4 below so the CTA stays visible/clickable in front of
   the model. Parented to .project (a sibling of the title) for the same
   stacking-context reason as photos3d: nested under .project__media its z-index
   would be trapped below the title. orion-model.js writes its left/top/width/
   height to overlay the media box; pointer-events:none lets clicks fall through
   to the title/button. */
.project--orion .orion-model {
  position: absolute;
  pointer-events: none;
  z-index: 3;
}
/* Keep the subtitle + CTA in front of the model (the canvas bleeds down past the
   media box via OVERFLOW, and the model sits at z-index 3). */
.project--orion .project__meta {
  z-index: 4;
}
/* The fallback <img> is HIDDEN by default so it never flashes before the model
   draws — it still occupies layout (opacity, not display) so it gives the mobile
   media box its height. orion-model.js reveals it (adds .is-model-failed) only
   when the 3D genuinely can't load: no WebGL, or the GLB fails to fetch/parse. */
.project--orion .project__media img {
  opacity: 0;
  transition: opacity 300ms ease;
  /* Reserve the media box's final height from first paint so the content below
     doesn't jump when the heavy 3D model (or the lazy fallback <img>) finishes
     loading. The model canvas is absolutely positioned and contributes no
     layout height, and the fallback <img> is opacity:0 + lazy — so without an
     explicit size the box collapses to 0 until bytes arrive, then snaps open.
     width + aspect-ratio give the box glasses.webp's 1760×560 footprint with
     zero bytes downloaded; the existing max-width/max-height caps still bound
     it, so the reserved size matches what the loaded image would occupy. */
  width: 100%;
  aspect-ratio: 1760 / 560;
}
.project--orion .project__media.is-model-failed img {
  opacity: 1;
}

/* Gradient fill + gradient 1px outside stroke for the three featured project
   titles. Painted as three layers via the h2 and two pseudos:
   - h2 itself: just the stacking context. The actual text is color: transparent
     so the h2's own glyph rendering is invisible; the visible fill comes from
     ::after instead. h2's text still exists for fit-text measurement and a11y.
   - ::before pseudo (z-index: -1, paints in step 2 of the stacking context):
     stroke layer. The SVG filter (#title-outline-stroke) dilates the glyph
     alpha by 1px and floods it with white — producing an opaque fattened
     glyph (original + 1px outward). The SVG mask (#title-stroke-mask) then
     modulates that shape's alpha via a left→right linearGradient (0.65 → 0.8
     stop-opacity), giving the gradient halo.
   - ::after pseudo (default z-index, paints in step 6 — AFTER ::before):
     fill layer. background-clip: text with an OPAQUE grey gradient
     (#ffffff → #c5c6c6, the opaque equivalents of 100% → 75% white over the
     page bg). Opaque because the fattened stroke behind extends inside each
     glyph; an alpha fill would let that white bleed through and wash out the
     gradient on the right side. The fill on top covers the inside portion of
     the stroke, so only the 1px halo outside each glyph stays visible —
     producing a clean outside stroke without the AA dark-dip a feComposite
     operator="out" ring would have left at the glyph boundary.
   Pseudos read their text from data-title-text and re-wrap naturally at the
   same break point as the h2's <br> markup (one space per multi-row title). */
.project--orion .project__title,
.project--displayglasses .project__title,
.project--photos3d .project__title {
  position: relative;
  isolation: isolate;
  color: transparent;
}

.project--orion .project__title::before,
.project--displayglasses .project__title::before,
.project--photos3d .project__title::before {
  content: attr(data-title-text);
  position: absolute;
  inset: 0;
  z-index: -1;
  color: #fff;
  /* Half-width (0.5px) stroke on mobile; the desktop breakpoint below swaps
     in the full 1px #title-outline-stroke filter. */
  filter: url(#title-outline-stroke-mobile);
  -webkit-mask: url(#title-stroke-mask);
  mask: url(#title-stroke-mask);
  pointer-events: none;
}

/* Fake drop shadow: an empty span (no text node, so fit-text's textContent
   measurement is untouched) whose ::before re-renders the title from
   data-title-text and runs it through #title-drop-shadow. Sits at the back
   (z-index: -2, behind the stroke) so only the masked fringe shows. */
.project--orion .project__title-shadow,
.project--displayglasses .project__title-shadow,
.project--photos3d .project__title-shadow {
  position: absolute;
  inset: 0;
  z-index: -2;
  pointer-events: none;
}

.project--orion .project__title-shadow::before,
.project--displayglasses .project__title-shadow::before,
.project--photos3d .project__title-shadow::before {
  content: attr(data-title-text);
  position: absolute;
  inset: 0;
  color: #000;
  filter: url(#title-drop-shadow);
}

.project--orion .project__title::after,
.project--displayglasses .project__title::after,
.project--photos3d .project__title::after {
  content: attr(data-title-text);
  position: absolute;
  inset: 0;
  color: transparent;
  background-image: linear-gradient(to right, #ffffff, #c5c6c6);
  -webkit-background-clip: text;
  background-clip: text;
  pointer-events: none;
}

/* Restore the full 1px stroke on desktop (mobile uses the 0.5px variant). */
@media (min-width: 768px) {
  .project--orion .project__title::before,
  .project--displayglasses .project__title::before,
  .project--photos3d .project__title::before {
    filter: url(#title-outline-stroke);
  }
}

/* ---------------------------------------------------------------------------
   Desktop: project layouts honor the per-project specs.
   --------------------------------------------------------------------------- */

@media (min-width: 768px) {
  .project {
    row-gap: 48px;
  }

  .project__media img {
    max-width: 100%;
    max-height: 50vh;
  }

  /* ---- Orion ---- */
  /* Title on row 1, single line, sized off the inner column (cqi) so it
     fills the 1048px column edge-to-edge at every viewport. Image on row 2
     pulled up 24px so its top edge crosses into the bottom 24px of the title.
     Meta in right 4 cols on row 3. row-gap: 0 keeps the negative margin math
     stable. */
  .project--orion {
    grid-template-rows: auto auto auto;
    row-gap: 0;
  }
  .project--orion .project__title {
    grid-column: 1 / -1;
    grid-row: 1;
  }
  .project--orion .project__media {
    grid-column: 1 / -1;
    grid-row: 2;
    z-index: 0;
    margin-top: -24px;
  }
  .project--orion .project__meta {
    grid-column: 9 / -1;
    grid-row: 3;
    /* Overlap the bottom of the image by 48px. row-gap is 0 so meta's natural
       top sits at image-bottom; a -48px margin lifts it 48px into the image. */
    margin-top: -48px;
    align-items: flex-start;
    text-align: left;
  }
  .project--orion .project__subtitle {
    text-align: left;
  }

  /* ---- Generative UI ---- */
  /* Image full-width on row 1 behind text. Title on row 2 spans full 12 cols,
     sized off the inner column (cqi) so "Generative UI" fills the 1048px
     column. Pulled up so only its top ~32px overlaps the image bottom.
     Meta on row 3: subtitle (left, max 2 lines) + button (right, hugging
     the grid edge). */
  .project--genui {
    grid-template-rows: auto auto auto;
    row-gap: 0;
    /* Nudge the whole block up — the screen's header-aware padding visually
       centers content, but with the image dominating row 1 the perceived
       center sits a touch low. */
    margin-top: -32px;
  }
  .project--genui .project__media {
    grid-column: 1 / -1;
    grid-row: 1;
    z-index: 0;
  }
  .project--genui .project__media img {
    /* Cap below the default 50vh so image + title + meta + padding fits within
       100dvh on common laptop heights (~800–900px). At 56vh the section grew
       taller than the viewport, defeating the screen's justify-content: center
       and leaving empty space below the snapped-to-top content. */
    max-height: 44vh;
  }
  .project--genui .project__title {
    grid-column: 1 / -1;
    grid-row: 2;
    align-self: start;
    margin-top: -32px;
  }
  .project--genui .project__meta {
    grid-column: 1 / -1;
    grid-row: 3;
    margin-top: 0;
    flex-direction: row;
    align-items: center;
    gap: 32px;
    /* space-between pushes the subtitle left and the button right edge of the
       grid. */
    justify-content: space-between;
  }
  .project--genui .project__subtitle {
    /* Force the subtitle to wrap to at least 2 lines on desktop so the
       "AI generated UI for wearable devices" line breaks rather than
       running flat under the giant title. */
    max-width: 22ch;
  }

  /* ---- Web Apps for Display Glasses ---- */
  /* Title two lines (via <br>) in left 9 cols; placeholder in right 5 cols.
     Subtitle + button left-aligned exactly 32px below title. Media is
     positioned absolutely inside the project so it doesn't inflate the
     title's grid row — without that, the spanning media stretches row 1
     and meta drifts far below the title. */
  .project--displayglasses {
    grid-template-rows: auto auto;
    row-gap: 32px;
    position: relative;
    /* Match the absolute media's max height so the project box contains the
       placeholder instead of letting it overflow the section. Same pattern
       as .project--photos3d below. */
    min-height: 64vh;
    /* Center the title+meta row group within the 64vh box so its vertical
       center lines up with the absolute placeholder's vertical center. */
    align-content: center;
  }
  .project--displayglasses .project__title {
    grid-column: 1 / 10;
    grid-row: 1;
    margin-top: 0;
    /* Optical left-edge alignment: nudge the title left by ~its cap side
       bearing so its visual edge lines up with the subtitle/button below it
       rather than sitting a hair inset. em-based so it tracks the title size;
       tune to taste (more negative = further left). */
    margin-left: -0.07em;
    line-height: 1;
    white-space: normal;
  }
  .project--displayglasses .project__media {
    /* Take the placeholder out of grid flow so it doesn't affect row sizing.
       Anchored to the right of the project, vertically centered. Width is
       5/12 of the project box — one column less than the 50% it used to be —
       so the placeholder visually clears one more column before its 400px
       cap kicks in. */
    position: absolute;
    top: 0;
    bottom: 0;
    right: 0;
    width: calc(5 / 12 * 100%);
    z-index: 0;
    display: flex;
    align-items: center;
    justify-content: flex-end;
  }
  .project--displayglasses .project__meta {
    grid-column: 1 / 8;
    grid-row: 2;
    align-self: start;
    margin-top: 0;
  }
  /* ---- 3D Photos ---- */
  /* Image absolutely positioned in the left half so it doesn't inflate the
     grid rows — same approach as Perspective. Title row 1 single line; meta
     row 2 in cols 7-12, sits 32px below title (matching Perspective spacing). */
  .project--photos3d {
    grid-template-rows: auto auto;
    row-gap: 32px;
    position: relative;
    /* The media box is the left half of this box, full-height (top:0;bottom:0
       below), so this min-height sets the media box's height. 60vh suits
       landscape/desktop, but on a tall portrait viewport (e.g. tablet
       portrait) 60vh makes the box very tall while its width is only half a
       (narrow) column — the model then fits to that width and floats small and
       centered with a big gap below, no longer meeting the title's top. So cap
       the height to the model's preferred ~0.8 aspect: the media box is half
       the column wide, and width ÷ 0.8 = column × 0.625. On landscape/wide
       viewports 60vh is already the smaller term, so those layouts are
       unchanged; the cap only kicks in when the viewport is tall/narrow. */
    min-height: min(
      60vh,
      calc(0.625 * (100vw - 2 * var(--page-gutter))),
      calc(0.625 * var(--content-max))
    );
    align-content: start;
    padding-top: 24px;
  }
  .project--photos3d .project__media {
    position: absolute;
    top: 0;
    bottom: 0;
    left: 0;
    width: 50%;
    /* z-index intentionally left auto (see the model-canvas block above): the
       box must not form a stacking context or the top canvas can't paint above
       the title. The absolute positioning + source order keep it behind the
       title's content regardless. */
    z-index: auto;
    display: flex;
    align-items: flex-start;
    justify-content: flex-start;
    margin-top: 0;
  }
  .project--photos3d .project__media img {
    max-height: 60vh;
    max-width: 100%;
  }
  .project--photos3d .project__title {
    grid-column: 1 / -1;
    grid-row: 1;
    align-self: start;
    margin-top: 0;
  }
  .project--photos3d .project__meta {
    grid-column: 7 / -1;
    grid-row: 2;
    align-self: start;
    align-items: flex-start;
    text-align: left;
    margin-top: 0;
  }
  .project--photos3d .project__subtitle {
    text-align: left;
    text-wrap: balance;
  }
}

/* ---------------------------------------------------------------------------
   Skills — "A Few More Things" card grid.
   Two layouts share the same DOM via CSS grid-template-areas:
   desktop is 3 columns × 3 row tracks (with PT and 3D Art spanning 2 tracks
   and Spark AR spanning all 3); mobile is 2 columns × 4 row tracks (with
   Spark AR + 3D Art spanning multiple tracks). Sized via aspect-ratio so
   the grid scales with available width while keeping a consistent height.
   --------------------------------------------------------------------------- */

.screen--skills .screen__inner {
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: 32px;
  max-width: 960px;
}

.skills__title {
  text-align: center;
  margin: 0;
}

.skills__grid {
  width: 100%;
  max-width: 960px;
  display: grid;
  gap: 16px;
  /* Mobile: 2 cols × 5 tracks. PT/GenUI span rows 1-2 (2/5 of the height);
     Spark AR spans rows 3-5 left; 3D Art spans rows 3-4 right; Watch Building
     row 5 right. Each column totals 5 tracks + 4 gaps. */
  grid-template-columns: 1fr 1fr;
  grid-template-rows: repeat(5, 1fr);
  grid-template-areas:
    "pt   gu"
    "pt   gu"
    "sa   art"
    "sa   art"
    "sa   gd";
  /* Picked so PT/GenUI cells are roughly square and Spark AR reads as a
     tall portrait at typical phone widths. */
  aspect-ratio: 360 / 690;
  /* Cap mobile grid height so it doesn't overflow short viewports. When the
     cap kicks in, the grid keeps its aspect ratio and just narrows from the
     center (max-width still 960). */
  max-height: min(640px, 90dvh);
}

@media (min-width: 768px) {
  .skills__grid {
    /* Desktop: middle column wider (4fr vs 3fr) so GenUI/3D Art frame the
       centerline. Three equal row tracks; PT and 3D Art each span 2 tracks,
       Spark AR spans all 3 — so every column totals the same height
       (3 tracks + 2 gaps). */
    grid-template-columns: 3fr 4fr 3fr;
    grid-template-rows: repeat(3, 1fr);
    grid-template-areas:
      "pt   gu   sa"
      "pt   art  sa"
      "gd   art  sa";
    /* 960 wide × ~632 tall keeps row tracks ~200px — Spark AR reads as a
       tall portrait and GenUI/Watch Building sit ~2:1 landscape. */
    aspect-ratio: 960 / 632;
  }
}

/* ---- Card base (overlay style: full-bleed image with gradient + bottom title) ---- */

/* .skill-card is a transparent shell — the grid item with NO visible chrome.
   All paint (bg, radius, padding, content layout) lives on .skill-card__inner.
   Splitting the two layers lets the ::after below extend the pointer hit
   area out into the grid gap (overflow: visible here) without breaking the
   card's clipped rounded corners (overflow: hidden on __inner). */
.skill-card {
  position: relative;
  display: block;
  min-width: 0;
  min-height: 0;
  /* Anchors carry default text-decoration and link color — strip both so the
     card's own typography rules apply. */
  text-decoration: none;
  color: inherit;
  -webkit-tap-highlight-color: transparent;
  /* Each card gets its own perspective so the hover tilt pivots about its
     own center, not the grid's. */
  perspective: 1000px;
  /* z-index is discrete, but transition-delay still defers the swap.
     Holding the change for 200ms (half of the 400ms shadow transition)
     means the card doesn't jump stacking order while the shadow is still
     mid-fade — which is what was causing the shadow "flash" against
     neighbours on both hover-in and hover-out. */
  transition: z-index 0s 200ms;
}

/* Desktop only: hide the OS cursor over a card. The .skill-cursor pip takes
   its place (positioned by skill-cursor.js). Touch/coarse pointers keep the
   native cursor — taps work as normal anchor activations. The descendant
   selector beats the global `h3 { cursor: text }` and `a { cursor: pointer }`
   rules so .skill-card__title and the card anchor both stay invisible. */
@media (hover: hover) and (pointer: fine) {
  .skill-card,
  .skill-card * { cursor: none; }

  /* Lift the hovered card above siblings so its drop shadow renders over
     the adjacent cards rather than under them. The delay above defers
     the swap until the shadow is well into its fade. */
  .skill-card:hover { z-index: 10; }
}

/* Hit-area pseudo: stretches 8px beyond the card on every side so each card's
   pointer surface meets the adjacent card's surface at the exact midpoint of
   the 16px grid gap. Result: no dead zones between cards — the custom cursor
   stays "on" continuously while moving across the whole grid. Transparent and
   stacked above __inner via z-index so pointermove hits this pseudo (which
   targets the parent <a>); the card looks the same because nothing is painted. */
.skill-card::after {
  content: "";
  position: absolute;
  inset: -8px;
  z-index: 1;
}

.skill-card__inner {
  position: relative;
  width: 100%;
  height: 100%;
  background: #212225;
  border-radius: 16px;
  overflow: hidden;
  display: flex;
  flex-direction: column;
  --card-pad: 12px;
  padding: var(--card-pad);
  min-width: 0;
  min-height: 0;
  /* Hover lift + tilt + shadow.
     - `scale` carries the lift (400ms ease so the card "lifts" on hover and
       "settles" on hover-out).
     - `transform` is the tilt rotation. The same expression lives in the
       rest rule and is never overridden by the :hover rule — what changes
       between rest and hover is the `--tilt-amount` multiplier (0 in rest,
       1 in hover), which the browser interpolates over 400ms via @property.
       That gives the rotation an envelope that ramps in/out with the scale
       lift, so entering on an edge doesn't snap to full tilt.
     - `transform` itself has NO transition. Cursor movement updates
       --tilt-x/--tilt-y instantly, the calc re-evaluates, and the new
       rotation applies frame-for-frame — no low-pass filter (any non-
       trivial easing on `transform` here produces visible stepping because
       the tilt target moves every frame).
     --tilt-x/--tilt-y are written by skill-cursor.js (range -1 → 1, 0 =
     centred); --tilt-amount is the rest/hover envelope. */
  --tilt-amount: 0;
  scale: 1;
  transform:
    rotateX(calc(var(--tilt-y, 0) * var(--tilt-amount) * -12deg))
    rotateY(calc(var(--tilt-x, 0) * var(--tilt-amount) * 12deg));
  transition:
    scale 400ms ease-in,
    --tilt-amount 400ms ease-in,
    box-shadow 400ms ease-in;
}

@media (hover: hover) and (pointer: fine) {
  .skill-card:hover .skill-card__inner {
    transition:
      scale 400ms ease-out,
      --tilt-amount 400ms ease-out,
      box-shadow 400ms ease-out;
    /* Tilt away from the cursor: mouse on the right (+tilt-x) rotates the
       right edge back (+rotateY); mouse on the top (-tilt-y) rotates the
       top edge back (+rotateX). The transform expression itself stays in
       the rest rule — :hover only flips the envelope from 0 to 1. */
    --tilt-amount: 1;
    scale: 1.08;
    box-shadow: 0 8px 24px rgba(0, 0, 0, 0.35);
  }

  /* Press feedback. JS (skill-cursor.js) sets .is-pressed on pointerdown
     and swaps it for .is-releasing on pointerup/leave/cancel — two classes
     so the press-down (snappy 120ms ease-out punch-in) and release
     (200ms ease-out-back overshoot) can use different timing functions.
     The anchor's click event fires on mouseup, after pointerup, so the
     full press → release animation plays before any navigation / modal
     hash change kicks in. Tilt vars are preserved during the press so
     the card stays oriented toward the cursor as it shrinks. */
  .skill-card.is-pressed .skill-card__inner {
    transition:
      scale 120ms ease-out,
      --tilt-amount 400ms ease-out,
      box-shadow 120ms ease-out;
    /* 0.96× the hover scale of 1.08 — a 4% squeeze from the lifted size,
       not all the way down past the rest scale. --tilt-amount inherits its
       value (1) from the :hover rule, so the tilt stays at full strength
       through the press. */
    scale: calc(1.08 * 0.96);
  }

  /* Released. The --tilt-amount value resolves from the cascade by hover
     state: when the cursor is still over the card, :hover sets it to 1 and
     the tilt stays full through the 200ms scale bounce; when the cursor has
     left, the rest rule's 0 takes over and the rotation eases back to flat
     via the --tilt-amount 400ms ease-in transition below. No `transform`
     transition needed — the rotation animates indirectly through its
     multiplier, which keeps cursor tracking inside the bounce window
     instant. */
  .skill-card.is-releasing .skill-card__inner {
    transition:
      scale 200ms cubic-bezier(0.34, 1.56, 0.64, 1),
      --tilt-amount 400ms ease-in,
      box-shadow 200ms ease-out;
  }
}

@media (min-width: 768px) {
  .skill-card__inner { --card-pad: 16px; }
}

.skill-card--ptoolkit { grid-area: pt; }
.skill-card--genui    { grid-area: gu; }
.skill-card--sparkar  { grid-area: sa; }
.skill-card--art      { grid-area: art; }
.skill-card--watchbuilding { grid-area: gd; }

.skill-card__media {
  position: absolute;
  inset: 0;
  z-index: 0;
  pointer-events: none;
}

.skill-card__media img,
.skill-card__media video {
  width: 100%;
  height: 100%;
  object-fit: cover;
  display: block;
}

/* iOS Safari paints a native "play" button glyph over any inline <video> it
   has decided not to autoplay (Low Power Mode, first-load energy heuristics).
   On these decorative clips that's wrong twice over: the glyph shows on a
   silent background video, and because the skill card is a link the glyph
   eats the first tap (so the modal only opened on the second). These videos
   are purely decorative — no one ever scrubs them — so suppress the native
   start-playback button and surrounding controls. The poster (skill cards) or
   first painted frame (modal/project bodies) then shows cleanly, and the tap
   always falls through to the card link. Scoped to the decorative contexts so
   any future video that genuinely needs controls is untouched. */
.skill-card__media video::-webkit-media-controls-start-playback-button,
.media-row video::-webkit-media-controls-start-playback-button,
.skill-card__media video::-webkit-media-controls,
.media-row video::-webkit-media-controls {
  display: none !important;
  -webkit-appearance: none;
}

/* Keep the video on Chrome's composited (colour-managed) path at ALL times so
   its colour never changes between rest and hover.

   At rest Chrome presents the video on a hardware-overlay (Direct Composition)
   plane, which is scanned out WITHOUT colour management to the display profile.
   The instant the card's hover scale/tilt makes the video quad non-axis-aligned
   the overlay plane is impossible, so Chrome recomposites the video as a
   colour-managed GPU texture — and those two paths render visibly different
   colour (the composited one reads greyer). The file's colour tags are now
   consistent BT.709, but this overlay-vs-texture gap is independent of the file,
   so the path switch still shifts colour. Path selection is a runtime heuristic,
   which is why it's intermittent across reloads.

   To stop the switch the video must be drawn into its OWN render surface — quads
   inside a child render pass are never overlay-promoted, so the video stays on
   the composited path in every state. The trigger has to be something DComp
   cannot fold into the overlay quad: `opacity` < 1 and 2D scale CAN be folded
   (DComp visuals carry their own opacity/transform), which is why opacity:0.999
   did nothing — Chrome kept using the overlay. A `filter` CANNOT be folded; any
   non-empty filter list forces the render surface. This drop-shadow is fully
   transparent with zero offset and zero blur, so it paints nothing and is
   colour-neutral — it exists purely to force the surface. will-change keeps the
   layer from being demoted under scroll pressure. */
.skill-card__media video {
  filter: drop-shadow(0 0 0 transparent);
  will-change: transform;
  backface-visibility: hidden;
}

/* Gradient sits over the image: transparent at top → 65% black at bottom,
   with the spec'd stops so the title reads cleanly against any image. */
.skill-card__media::after {
  content: "";
  position: absolute;
  inset: 0;
  background: linear-gradient(
    to bottom,
    rgba(0, 0, 0, 0) 0%,
    rgba(0, 0, 0, 0.15) 50%,
    rgba(0, 0, 0, 0.35) 75%,
    rgba(0, 0, 0, 0.65) 100%
  );
}

.skill-card__title {
  position: relative;
  z-index: 1;
  margin: 0;
  /* Push to the bottom of the flex column. In overlay cards the media is
     absolutely positioned so the title is the only flex item — margin-top:
     auto absorbs all available space above it. */
  margin-top: auto;
  font-family: var(--font-sans);
  font-weight: 700;
  font-size: 20px;
  line-height: 24px;
  letter-spacing: -0.01em;
  color: var(--color-fg);
  /* h3 token uppercases its text — these are display titles, not mono labels. */
  text-transform: none;
}

@media (min-width: 768px) {
  .skill-card__title {
    font-weight: 700;
    font-size: 32px;
    line-height: 40px;
  }
}

.skill-card__badge {
  position: relative;
  z-index: 1;
  align-self: flex-start;
  padding: 0 8px;
  background: var(--color-fg);
  color: #0c0c0e;
  font-family: var(--font-sans);
  font-weight: var(--fw-medium);
  font-size: 12px;
  line-height: 20px;
  border-radius: 9999px;
  white-space: nowrap;
}

@media (min-width: 768px) {
  .skill-card__badge {
    font-size: 12px;
    line-height: 24px;
  }
}

/* ---- Perspective Toolkit overrides — stacked layout. ----
   Image is contained (not full-bleed) so the plugin UI reads clearly with
   the card's dark background framing it. Title at top, badge at bottom,
   image flexes to fill the middle. */
.skill-card--ptoolkit .skill-card__title {
  margin-top: 0;
}

/* Media is positioned over the full card, but the image itself sits at its
   natural aspect (capped at card width/height), pinned to the top-right via
   align-items: flex-start + justify-content: flex-end. Title and badge layer
   over the resulting dark area below the image. */
.skill-card--ptoolkit .skill-card__media {
  position: absolute;
  inset: 0;
  margin: 0;
  display: flex;
  align-items: flex-start;
  justify-content: flex-end;
}

/* No gradient on PT — the card bg already provides the contrast frame for
   the title and badge. */
.skill-card--ptoolkit .skill-card__media::after {
  display: none;
}

.skill-card--ptoolkit .skill-card__media img {
  width: auto;
  height: auto;
  max-width: 100%;
  max-height: 100%;
  object-fit: contain;
  display: block;
}

/* Media is out of flex flow (absolutely positioned), so the badge needs an
   explicit auto top margin to land at the bottom of the card. */
.skill-card--ptoolkit .skill-card__badge {
  margin-top: auto;
}

/* ---------------------------------------------------------------------------
   About
   --------------------------------------------------------------------------- */

/* Override the base .screen flex layout with a 2-row grid so the about
   content occupies the flexible top row (vertically centered within the area
   *above* the footer) and the footer sits in an auto row at the bottom.
   Drop the screen's own bottom padding — the footer provides its own 128px
   bottom space. */
.screen--about {
  display: grid;
  grid-template-rows: 1fr auto;
  /* Explicit single column. Without this, the implicit column is auto-sized
     (= max-content of children), which means the inner's width: 100% resolves
     against a track that itself shrinks to content — circular sizing collapses
     the whole about layout. minmax(0, 1fr) fills the container without
     letting an oversized child stretch the track past the viewport. */
  grid-template-columns: minmax(0, 1fr);
  justify-items: center;
  padding-bottom: 0;
}

.screen--about > .screen__inner {
  align-self: center;
  /* width: 100% on .screen__inner already gives the about grid a stable
     1048px (max) width to lay out against. justify-self: center horizontally
     centers the capped box in the track on viewports wider than --content-max;
     stretch would fall back to `start` alignment when max-width clamps it,
     left-biasing the container on desktop. */
  justify-self: center;
}

/* Mobile: simple stack — collage above copy. Desktop overrides to a two-
   column grid with copy on the left and a fixed-size photo collage on the right. */
.about {
  display: grid;
  grid-template-columns: 1fr;
  gap: 0;
  align-items: center;
}

.about__collage { order: -1; }
.about__copy    { order: 0; }

.about__title {
  font-family: var(--font-sans);
  font-weight: var(--fw-semibold);
  font-size: clamp(64px, 22vw, 128px);
  line-height: 1;
  letter-spacing: -0.02em;
  color: var(--color-fg);
  /* Optical left-edge alignment: nudge the title left by ~its cap side bearing
     so its visual edge lines up with the body copy below it rather than sitting
     a hair inset. Value is in em so it tracks the clamp() title size; tune to
     taste (more negative = further left). */
  margin: 0 0 0 -0.07em;
  /* Mobile: width: min-content sizes the title to its longest word, forcing
     "Hey" and "There!" onto separate lines regardless of viewport width.
     Desktop overrides with `white-space: nowrap`, which makes the whole
     phrase a single unbreakable token — min-content then equals max-content
     and the title sits on one line. */
  width: min-content;
  /* Stack above the collage when the mobile -92px overlap pulls the title
     up behind the photos. position: relative is required for z-index to
     take effect; on desktop the collage doesn't overlap, so this is inert. */
  position: relative;
  z-index: 4;
}

.about__copy {
  display: flex;
  flex-direction: column;
  gap: 24px;
  align-items: flex-start;
  width: 100%;
}

.about__copy p {
  /* Size/leading come from the body token (24/32); only the secondary colour
     is specific to the about copy. */
  color: var(--color-secondary);
}

.about__copy strong {
  color: var(--color-fg);
  font-weight: var(--fw-semibold);
}

.about__cta {
  margin-top: 8px;
}

/* Collage: three vertical-pill photos in a fixed composition. The reference
   frame is the desktop spec (420 × 680, with photos at 240 × {320, 400}); both
   mobile and desktop use the same percentage-based pill sizes/positions so the
   layout is identical at every viewport — only the collage's outer dimensions
   change between breakpoints (mobile = 75% of column, desktop = fixed 420×680).
   On mobile the collage is right-aligned within the page-gutter-inset column
   and overlaps the copy below it by 92px. */
.about__collage {
  position: relative;
  width: 75%;
  justify-self: end;
  margin: 0 0 -92px;
  /* Lock the box to the desktop spec's aspect ratio. Desktop overrides with
     explicit width + height, which makes aspect-ratio a no-op there. */
  aspect-ratio: 420 / 680;
}

.about__photo {
  position: absolute;
  /* All three pills are 240/420 of the collage's width. Heights differ per
     pill and are set on the modifier rules below. */
  width: calc(240 / 420 * 100%);
  border-radius: 9999px;
  overflow: hidden;
  /* Placeholder fill shows inside the pill until its image decodes, so each
     frame reads as a deliberate placeholder rather than an empty hole while
     the photos load in on slow connections. Matches the "A Few More Things"
     grid cards (.skill-card__inner). The base <img> is opaque and covers the
     whole pill, so this is fully hidden once it arrives. */
  background: #212225;
  /* Spec: 64px blur, 16px y, 35% opacity black. */
  box-shadow: 0 16px 64px rgba(0, 0, 0, 0.35);
  /* mouse-parallax.js writes the `translate` property each frame while the
     about section is in view — kept separate from `transform` below so the
     hover scale/tilt animation doesn't fight the per-frame parallax write.
     will-change covers both so the compositor promotes the layer up front. */
  will-change: translate, transform;
  /* Hover lift + tilt. Same architecture as .skill-card__inner above:
     - `scale` carries the lift (400ms ease) — same expression in rest and
       hover, only the value differs.
     - `transform` is the tilt rotation, gated by the `--tilt-amount`
       multiplier (0 in rest, 1 in hover). The browser interpolates
       --tilt-amount over 400ms via @property so the rotation ramps in
       from flat instead of snapping to its full value when the cursor
       enters near an edge.
     - `transform` itself has NO transition. Cursor movement updates
       --tilt-x/--tilt-y instantly, the calc re-evaluates, and the new
       rotation applies frame-for-frame. about-cycle.js writes
       --tilt-x/--tilt-y on pointermove (-1 → 1, centred = 0). */
  --tilt-amount: 0;
  scale: 1;
  transform:
    perspective(1000px)
    rotateX(calc(var(--tilt-y, 0) * var(--tilt-amount) * -12deg))
    rotateY(calc(var(--tilt-x, 0) * var(--tilt-amount) * 12deg));
  transition:
    scale 400ms ease-in,
    --tilt-amount 400ms ease-in;
}

@media (hover: hover) and (pointer: fine) {
  .about__photo:hover {
    transition:
      scale 400ms ease-out,
      --tilt-amount 400ms ease-out;
    --tilt-amount: 1;
    scale: 1.08;
  }
}

.about__photo img {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  object-fit: cover;
  display: block;
}

/* Three-frame hover cycle. Each pill stacks its base image plus two
   alt frames; about-cycle.js advances the visible frame by two on
   each hover so the resting image rotates through all three across
   successive hovers. CSS only sets the initial state (alts hidden);
   JS owns opacity from the first hover onward. */
.about__photo__frame {
  opacity: 0;
}

/* Stacking order (back → front): dog, whale, me. The "me" pill sits in the
   middle of the composition and reads as the focal point, so it layers above
   the other two where they overlap. Positions are expressed as percentages of
   the collage so the same composition holds at mobile and desktop. */
.about__photo--dog {
  top: 0;
  right: 0;
  height: calc(400 / 680 * 100%);
  z-index: 1;
}

.about__photo--whale {
  top: calc(360 / 680 * 100%);
  left: calc(120 / 420 * 100%);
  height: calc(320 / 680 * 100%);
  z-index: 2;
}

.about__photo--me {
  top: calc(180 / 680 * 100%);
  left: 0;
  height: calc(320 / 680 * 100%);
  z-index: 3;
}

@media (min-width: 768px) {
  /* Copy on the left fills the remaining width; the collage on the right
     is a fixed-size composition (420 × 680) so the three pills hit their
     spec coordinates exactly regardless of viewport width. */
  .about {
    grid-template-columns: minmax(0, 1fr) 420px;
    column-gap: 48px;
    align-items: start;
  }

  .about__copy {
    order: 0;
  }

  /* "Hey There!" fits on one line at every desktop width — pin it so a
     narrowing copy column never wraps the title across two lines. */
  .about__title {
    white-space: nowrap;
  }

  /* Fixed-size collage on desktop. Explicit width + height override the
     mobile aspect-ratio, and `margin: 0` clears the mobile -92px overlap
     since copy and collage sit side-by-side (not stacked) on desktop. The
     photo composition is inherited from the percentage-based rules above. */
  .about__collage {
    order: 0;
    width: 420px;
    height: 680px;
    margin: 0;
  }
}

/* ---------------------------------------------------------------------------
   Glass button (shader-driven). Identical to the previous demo styling — the
   class names and DOM structure are what `button.js` instantiates against.
   --------------------------------------------------------------------------- */

.experiment-button {
  --h: 64px;
  /* Bleed is the transparent margin the canvas extends past the pill on every
     side; the hover effects (rim halo + the exterior chromatic glow) render
     into it. Wide enough to hold the exterior glow's outward falloff — the
     shader windows the glow to zero before this edge so it never hard-clips. */
  --bleed: 24px;
  position: relative;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  height: var(--h);
  /* Side padding sizes the pill itself (the label's breathing room to the rim).
     The hover effect doesn't live here — it renders into --bleed, the canvas
     margin that extends past the pill on every side — so this stays at the
     original 32px and the effect keeps its room regardless. */
  padding: 0 32px;
  border: 0;
  background: transparent;
  color: var(--color-fg);
  font-family: var(--font-mono);
  font-weight: var(--fw-semibold);
  font-size: 16px;
  letter-spacing: 0.06em;
  cursor: pointer;
  isolation: isolate;
  -webkit-tap-highlight-color: transparent;
  /* No text selection on the control. Stops an accidental tap-drag from
     highlighting the label (or spilling a selection into adjacent copy);
     -webkit-touch-callout suppresses the iOS long-press callout menu. Purely a
     selection/highlight suppression — focus, keyboard activation, and the
     accessible name are unaffected, so the button stays fully operable. */
  -webkit-user-select: none;
  user-select: none;
  -webkit-touch-callout: none;
  /* External-link variants use <a>, which would inherit the default link
     underline on the label. Strip it here so all variants render flush. */
  text-decoration: none;
}

.experiment-button:focus-visible {
  outline: 2px solid #ffffff80;
  outline-offset: 4px;
  border-radius: 9999px;
}

.experiment-button__canvas {
  position: absolute;
  top: calc(var(--bleed) * -1);
  left: calc(var(--bleed) * -1);
  width: calc(100% + var(--bleed) * 2);
  height: calc(100% + var(--bleed) * 2);
  display: block;
  pointer-events: none;
  z-index: 0;
}

.experiment-button__label {
  position: relative;
  z-index: 1;
  display: inline-block;
  /* -webkit- prefix is required: iOS Safari ignores the unprefixed property, so
     without it a tap-drag on the button still highlighted the label text. */
  -webkit-user-select: none;
  user-select: none;
  pointer-events: none;
  text-shadow: 0 1px 2px rgba(0, 0, 0, 0.6);
  white-space: nowrap;
}

.experiment-button--solid {
  color: #0c0c0e;
}

.experiment-button--solid .experiment-button__label {
  text-shadow: none;
}

.experiment-button--no-gl {
  background: #1a1a1f;
  border: 1px solid rgba(255, 255, 255, 0.18);
  border-radius: 9999px;
}

.experiment-button--no-gl.experiment-button--solid {
  background: #f0f0f2;
  border-color: rgba(0, 0, 0, 0.12);
}

.experiment-button--no-gl .experiment-button__canvas {
  display: none;
}

@media (min-width: 768px) {
  .experiment-button {
    font-size: 20px;
  }
}

/* ---------------------------------------------------------------------------
   Footer — social icons. Icons are rendered via CSS masks (background-color
   shows through the masked SVG shape) so each can recolor independently on
   hover. For Instagram, the hover swaps in the brand gradient via
   background-image. Mask-based icons can't use <img>, so the anchors are
   empty (aria-label provides the accessible name).
   --------------------------------------------------------------------------- */

.site-footer {
  width: 100%;
  display: flex;
  align-items: center;
  justify-content: center;
  gap: 32px;
  padding: 64px var(--page-gutter) 128px;
}

@media (min-width: 768px) {
  .site-footer {
    padding-top: 128px;
  }
}

.site-footer__icon {
  display: block;
  width: 48px;
  height: 48px;
  background-color: var(--color-fg);
  -webkit-mask-repeat: no-repeat;
          mask-repeat: no-repeat;
  -webkit-mask-position: center;
          mask-position: center;
  -webkit-mask-size: contain;
          mask-size: contain;
  /* Asymmetric hover timing: the transition defined on the base state runs
     when :hover is removed (hover-out → ease-in), and the transition on the
     :hover/:focus-visible state runs when it's applied (hover-in → ease-out).
     Browsers use whichever state's transition is current. */
  transition: background-color 240ms ease-in, background-image 240ms ease-in;
}

/* Threads uses a highlighter-marker effect on hover instead of a flat color
   swap, so the anchor's own mask is removed — the highlight rectangle has
   to extend beyond the SVG shape, and a parent mask would clip it. The
   glyph and the highlight live as separate pseudo layers instead. */
.site-footer__icon--threads {
  background-color: transparent;
  -webkit-mask-image: none;
          mask-image: none;
  position: relative;
}

.site-footer__icon--instagram {
  -webkit-mask-image: url("/assets/icons/instagram.svg");
          mask-image: url("/assets/icons/instagram.svg");
}

.site-footer__icon--linkedin {
  -webkit-mask-image: url("/assets/icons/linkedin.svg");
          mask-image: url("/assets/icons/linkedin.svg");
}

/* Shared hover/focus transition timing — ease-out on the way in. */
.site-footer__icon:hover,
.site-footer__icon:focus-visible {
  transition: background-color 240ms ease-out, background-image 240ms ease-out;
}

/* Yellow highlighter rectangle behind the glyph. Clip-path reveals it
   left-to-right; the gradient places the more-opaque section at the right
   so it lands last — like the end of a real marker stroke where more
   pigment pooled. The skew gives it the italic-style slanted edges of a
   real handheld marker, and because clip-path operates in pre-transform
   space, the sweeping leading edge naturally lands parallel to the static
   side slants. */
.site-footer__icon--threads::before {
  content: "";
  position: absolute;
  inset: 0;
  background-image: linear-gradient(to right,
    rgba(255, 224, 0, 0.55) 0%,
    rgba(255, 224, 0, 0.55) 60%,
    rgba(255, 200, 0, 0.85) 100%);
  border-radius: 4px;
  transform: skewX(-8deg);
  clip-path: inset(0 100% 0 0);
  /* ease-out-quad — fast start, decelerates to a stop. Reads like a quick
     swipe of the marker that settles at the end. Same curve in both
     directions so the retract feels consistent with the draw. */
  transition: clip-path 240ms cubic-bezier(0.25, 0.46, 0.45, 0.94);
  pointer-events: none;
}

.site-footer__icon--threads:hover::before,
.site-footer__icon--threads:focus-visible::before {
  clip-path: inset(0 0 0 0);
}

/* White Threads glyph layered on top of the highlight — same masked-block
   trick as the other icons, just scoped to ::after instead of the anchor. */
.site-footer__icon--threads::after {
  content: "";
  position: absolute;
  inset: 0;
  background-color: var(--color-fg);
  -webkit-mask: url("/assets/icons/threads.svg") no-repeat center / contain;
          mask: url("/assets/icons/threads.svg") no-repeat center / contain;
  pointer-events: none;
}

.site-footer__icon--linkedin:hover,
.site-footer__icon--linkedin:focus-visible {
  background-color: #0a66c2;
}

/* Instagram brand gradient — diagonal, the canonical 2016+ palette.
   background-image can't interpolate between a gradient and `none`, so
   fading via background-image would snap on the way out. Instead, the
   gradient lives on a ::after layer that fades its opacity in/out — both
   directions interpolate smoothly. The parent's mask clips the pseudo to
   the SVG shape automatically. */
.site-footer__icon--instagram {
  position: relative;
}

.site-footer__icon--instagram::after {
  content: "";
  position: absolute;
  inset: 0;
  background-image: linear-gradient(45deg,
    #feda75 0%,
    #fa7e1e 25%,
    #d62976 50%,
    #962fbf 75%,
    #4f5bd5 100%);
  opacity: 0;
  transition: opacity 240ms ease-in;
  pointer-events: none;
}

.site-footer__icon--instagram:hover::after,
.site-footer__icon--instagram:focus-visible::after {
  opacity: 1;
  transition: opacity 240ms ease-out;
}

/* ---------------------------------------------------------------------------
   Toast — slides down from the top, holds, then slides back up. Used by the
   contact modal for the post-send confirmation.
   --------------------------------------------------------------------------- */

.toast {
  position: fixed;
  top: 24px;
  left: 50%;
  z-index: 200;
  padding: 12px 24px;
  background: #1d1f23;
  border: 1px solid rgba(255, 255, 255, 0.08);
  border-radius: 9999px;
  box-shadow: 0 16px 48px rgba(0, 0, 0, 0.5);
  color: var(--color-fg);
  font-family: var(--font-mono);
  font-weight: var(--fw-medium);
  font-size: 12px;
  letter-spacing: 0.04em;
  white-space: nowrap;
  pointer-events: none;
  /* Hidden state: parked above the viewport. The extra -32px past -100%
     ensures the box-shadow doesn't peek over the top edge while at rest. */
  transform: translate(-50%, calc(-100% - 32px));
  /* ease-out-back — overshoots slightly before settling. Same curve in both
     directions so the exit has the same playful character as the entry. */
  transition: transform 600ms cubic-bezier(0.34, 1.56, 0.64, 1);
}

.toast--visible {
  transform: translate(-50%, 0);
}

/* ---------------------------------------------------------------------------
   Contact modal
   --------------------------------------------------------------------------- */

.contact-modal {
  position: fixed;
  inset: 0;
  z-index: 100;
  display: flex;
  align-items: center;
  justify-content: center;
  /* Min 48px gap between the dialog and every viewport edge — modal shrinks
     below max-width/height on small viewports to preserve the gap. */
  padding: 48px;
  background: rgba(0, 0, 0, 0.75);
  /* Modal is always laid out (so the dialog's rect stays measurable for the
     close-cursor calc) but invisible/non-interactive when closed. The dialog
     and close button run the slide+fade themselves; this rule fades the
     backdrop and gates pointer events. visibility is delayed on close so the
     fade can finish before the element drops out of the a11y tree. The fade
     out is delayed 200ms so the dialog slides visibly for the first half of
     the close before starting to disappear; total close duration is 400ms. */
  opacity: 0;
  visibility: hidden;
  pointer-events: none;
  transition: opacity 200ms ease-in 200ms, visibility 0s linear 400ms;
}

.contact-modal.is-open {
  opacity: 1;
  visibility: visible;
  pointer-events: auto;
  transition: opacity 200ms ease-out, visibility 0s linear 0s;
}

.contact-modal__dialog {
  position: relative;
  width: 100%;
  /* No fixed height — the dialog sizes to its content so the message textarea
     can grow the modal downward as more lines are typed. */
  max-width: 416px;
  background: #1d1f23;
  border: 1px solid rgba(255, 255, 255, 0.06);
  border-radius: 36px;
  box-shadow: 0 24px 64px rgba(0, 0, 0, 0.5);
  display: flex;
  /* Open: fade in over 200ms ease-out while sliding up over 600ms ease-out
     (opacity finishes first, the slide settles after). Close: slide down over
     400ms ease-in, opacity holds for 200ms then fades over 200ms (so the
     dialog stays readable while it slides away, then disappears). The close
     button (a sibling of the dialog, NOT a child — a transform on the dialog
     would make it the containing block for the position: fixed X) runs the
     same animation in parallel so the two move as one. */
  opacity: 0;
  transform: translateY(160px);
  transition: opacity 200ms ease-in 200ms, transform 400ms ease-in;
}

.contact-modal.is-open .contact-modal__dialog {
  opacity: 1;
  transform: translateY(0);
  transition: opacity 200ms ease-out, transform 600ms ease-out;
}

/* Close icon is always position: fixed and positioned via two CSS vars
   (--close-x / --close-y, in viewport coords). JS keeps the vars in sync —
   at rest they point to the dialog's top-right; in cursor-follow mode they
   track the cursor. The transform property is reserved for the open/close
   slide animation, so centering uses negative margin instead of
   translate(-50%, -50%). The X glyph itself is painted as a CSS mask over the
   button's background-color so it can be recoloured on open. */
.contact-modal__close {
  position: fixed;
  top: var(--close-y, 0);
  left: var(--close-x, 0);
  width: 32px;
  height: 32px;
  /* Centre the 32×32 icon on (--close-x, --close-y). */
  margin: -16px 0 0 -16px;
  padding: 0;
  border: 0;
  cursor: pointer;
  z-index: 2;
  background-color: var(--color-fg);
  -webkit-mask: url("/assets/icons/clear.svg") no-repeat center / 32px 32px;
          mask: url("/assets/icons/clear.svg") no-repeat center / 32px 32px;
  /* Matches the dialog's slide+fade so the X enters/exits in lockstep with the
     dialog corner it sits next to. setRestPosition() in modal.js compensates
     for the dialog's mid-animation transform so the top/left vars always
     point at the final rest spot — the visual slide is purely this transform. */
  opacity: 0;
  transform: translateY(160px);
  transition: opacity 200ms ease-in 200ms, transform 400ms ease-in;
}

.contact-modal.is-open .contact-modal__close {
  opacity: 0.8;
  transform: translateY(0);
  transition: opacity 200ms ease-out, transform 600ms ease-out;
}

/* JS toggles this class for the brief window after leaving cursor-follow mode
   so the X snaps back to its rest position over 100ms ease-out. The entry
   into cursor-follow mode is instant (no snap class) for lag-free tracking.
   Gated on .is-open so the selector outranks the .is-open transition rule
   above. */
.contact-modal.is-open .contact-modal__close--snap {
  transition: opacity 200ms ease-out, transform 600ms ease-out,
              top 100ms ease-out, left 100ms ease-out;
}

.contact-modal.is-open .contact-modal__close:hover,
.contact-modal.is-open .contact-modal__close:focus-visible,
.more-modal.is-open .more-modal__close:hover,
.more-modal.is-open .more-modal__close:focus-visible {
  opacity: 1;
  outline: none;
}

/* Cursor-follow mode (pointer is outside the dialog and the X is being used
   as the custom cursor): always full white so the icon reads cleanly while
   tracking the pointer. Force translateY(0) with no transform transition so
   the X locks to the cursor even if cursor-follow activates mid-open
   animation (and the transform-snap doesn't drag the icon away from the
   pointer afterwards). */
.contact-modal.is-open.is-cursor-following .contact-modal__close,
.more-modal.is-open.is-cursor-following .more-modal__close {
  opacity: 1;
  transform: translateY(0);
  transition: opacity 200ms ease-out;
}

/* Cursor-follow mode: hide the OS cursor on the backdrop. The icon's
   pointer-events drops so clicks fall through to the backdrop and trigger
   the close-on-outside-click handler. */
.contact-modal.is-cursor-following {
  cursor: none;
}

.contact-modal.is-cursor-following .contact-modal__close {
  pointer-events: none;
}

.contact-modal__form {
  flex: 1;
  display: flex;
  flex-direction: column;
  gap: 24px;
  padding: 24px;
  min-width: 0;
  min-height: 0;
}

/* Matches the "A Few More Things" card titles (.skill-card__title): 20/24
   weight 700 on mobile, bumping to 32/40 semibold at ≥768px. */
.contact-modal__title {
  margin: 0;
  font-family: var(--font-sans);
  font-weight: 700;
  font-size: 20px;
  line-height: 24px;
  letter-spacing: -0.01em;
  color: var(--color-fg);
}

@media (min-width: 768px) {
  .contact-modal__title {
    font-weight: var(--fw-semibold);
    font-size: 32px;
    line-height: 40px;
  }
}

.contact-modal__field {
  display: flex;
  flex-direction: column;
  gap: 8px;
  min-height: 0;
}

.contact-modal__field--message {
  min-height: 0;
}

.contact-modal__label {
  font-family: var(--font-mono);
  font-weight: var(--fw-medium);
  font-size: 12px;
  line-height: 16px;
  letter-spacing: var(--track-caps);
  text-transform: uppercase;
  color: var(--color-secondary);
}

.contact-modal__input {
  width: 100%;
  padding: 12px 16px;
  background: rgba(255, 255, 255, 0.04);
  border: 1px solid rgba(255, 255, 255, 0.12);
  border-radius: 28px;
  color: var(--color-fg);
  font-family: var(--font-sans);
  font-size: 16px;
  line-height: 24px;
  outline: none;
  transition: border-color 160ms ease, background-color 160ms ease;
}

.contact-modal__input::placeholder {
  color: rgba(255, 255, 255, 0.32);
}

.contact-modal__input:hover {
  border-color: rgba(255, 255, 255, 0.22);
}

.contact-modal__input:focus,
.contact-modal__input:focus-visible {
  border-color: var(--color-accent);
  background: rgba(255, 255, 255, 0.06);
}

.contact-modal__textarea {
  /* Roomier vertical padding than the single-line inputs (24 vs 12) for a
     comfortable message box; horizontal stays 16 so the text still lines up
     with the name/email fields above. */
  padding: 24px 16px;
  /* Starts ~3 lines tall (3 × 24px line-height + 48px padding + 2px border)
     and auto-grows downward via autosizeMessage() in modal.js. Capped so a very
     long message scrolls internally instead of pushing the modal off-screen. */
  min-height: 122px;
  max-height: 240px;
  overflow-y: auto;
  resize: none;
  font-family: var(--font-sans);
}

/* Web3Forms honeypot — must exist in the DOM but be invisible to humans. */
.contact-modal__honeypot {
  position: absolute;
  left: -9999px;
  opacity: 0;
  pointer-events: none;
}

.contact-modal__actions {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 24px;
}

.contact-modal__status {
  margin: 0;
  font-family: var(--font-mono);
  font-size: 12px;
  line-height: 20px;
  letter-spacing: 0.04em;
  color: var(--color-secondary);
  min-height: 20px;
}

.contact-modal__status--error { color: #ff6b6b; }

/* Modal opens → freeze the snap-scroll container behind it. body itself is
   already overflow: hidden globally, so the scroll lock just targets .screens. */
body.modal-open .screens {
  overflow: hidden;
}

/* Tighter padding on narrow viewports — 48px outer + 48px inner would leave
   almost no room for the form on a phone. */
@media (max-width: 600px) {
  .contact-modal {
    padding: 24px;
  }
  .contact-modal__form {
    padding: 24px;
    gap: 20px;
  }
}

/* ---------------------------------------------------------------------------
   Project page — full-viewport overlay shown when the router renders a
   markdown post (/threedphotos, /perspectivetoolkit, /orion). Replaces the
   snap-scroll experience with a long-form scrollable article.
   --------------------------------------------------------------------------- */

.project-page {
  position: fixed;
  inset: 0;
  z-index: 20;
  background: var(--color-bg);
  overflow-y: auto;
  /* Clip horizontally so .fullwidth media (which uses 100vw + negative
     margins to escape the centered article column) can't push the page
     into horizontal scroll. */
  overflow-x: clip;
  padding: calc(var(--header-h) + 24px) var(--page-gutter) 96px;
  display: flex;
  flex-direction: column;
  align-items: center;
}

.project-page[hidden] {
  display: none;
}

body.project-open .screens {
  /* Hide the home content behind the project page so screen readers and
     tab order don't reach into it while a project is open. */
  visibility: hidden;
}

/* Cold-load deep link to a project hash (set synchronously by the inline
   head script in index.html). The router is async — without these rules the
   home content paints first and the project page pops in over it. Hide
   .screens and force the project-page background visible so the user sees
   the dark page chrome from first paint until the router fills it in.
   Cleared in router.js once applyProject runs. */
html.cold-project .screens {
  visibility: hidden;
}
html.cold-project .project-page[hidden] {
  display: flex;
}

.project-page__article {
  width: 100%;
  max-width: 760px;
  color: var(--color-fg);
  /* Sit above the hero's 3D model: the Orion model canvas renders in front of
     its title (z-index 3) and bleeds downward past the hero's media box, so
     without this the glasses would paint over the top of the article body. A
     positioned z-index above the canvas's (3) lifts the whole article in front
     of the hero's model. */
  position: relative;
  z-index: 5;
}

.project-page__body {
  display: flex;
  flex-direction: column;
  gap: 24px;
  color: var(--color-fg);
  font-size: 16px;
  line-height: 1.5;
  /* Slide the .md content up after the hero morph settles. `backwards` fill
     keeps the from-state applied during the 400ms delay, so the body stays
     hidden under the hero until the morph is done — then it eases up over
     800ms. Re-fires on every project open because applyProject rebuilds the
     article via innerHTML. */
  animation: project-body-slide-in 800ms cubic-bezier(0.2, 0.7, 0.2, 1) 400ms backwards;
}

@keyframes project-body-slide-in {
  from { opacity: 0; transform: translateY(40px); }
  to   { opacity: 1; transform: translateY(0); }
}

@media (prefers-reduced-motion: reduce) {
  .project-page__body { animation: none; }
}

/* H1 = primary section title: Geist Mono Medium 48/56, white, centered.
   H2 = sub-section label: mono, medium, uppercase, white — matches the
   project-subtitle h4 token so the in-article section labels read
   consistently with the section subtitle on the home project tiles. H1 gets
   more top air than H2 to preserve the rhythm between primary sections and
   sub-sections within them. */
.project-page__body h1 {
  margin: 0;
  padding: 24px 0 0;
  font-family: var(--font-mono);
  font-weight: var(--fw-medium);
  font-size: 48px;
  line-height: 56px;
  color: var(--color-fg);
  text-align: center;
}

.project-page__body h2 {
  margin: 32px 0 0;
  font-family: var(--font-mono);
  font-weight: var(--fw-medium);
  font-size: 24px;
  line-height: 32px;
  text-transform: uppercase;
  letter-spacing: var(--track-caps);
  color: var(--color-fg);
  text-align: center;
}

.project-page__body p {
  margin: 0;
  color: var(--color-secondary);
}

/* Blue inline links — scoped to <p> so glass buttons in the body (e.g. the
   topLink CTA, which renders as a top-level <a class="experiment-button">,
   not inside a paragraph) stay un-coloured. No underline at rest; on hover
   a 1.5px line painted via background-image grows from left to right.
   Using a background gradient (not ::after) means the effect works without
   touching the markdown and animates correctly on links that wrap. */
.project-page__body p a {
  color: var(--color-accent);
  text-decoration: none;
  /* Background-position lands the line inside the descender region —
     0.4em up minus 4px down from the line-box bottom. */
  background-image: linear-gradient(currentColor, currentColor);
  background-position: 0 calc(100% - 0.4em + 5px);
  background-size: 0 1.5px;
  background-repeat: no-repeat;
  transition: background-size 240ms ease;
  /* Skip-ink: paint a bg-coloured halo around each glyph so descenders
     (g, p, y, j) punch out the underline running behind them. text-shadow
     layers above the background-image but below the glyph fill. */
  text-shadow:
    1.5px 0 var(--color-bg), -1.5px 0 var(--color-bg),
    0 1.5px var(--color-bg), 0 -1.5px var(--color-bg),
    1.5px 1.5px var(--color-bg), -1.5px -1.5px var(--color-bg),
    1.5px -1.5px var(--color-bg), -1.5px 1.5px var(--color-bg);
}

.project-page__body p a:hover,
.project-page__body p a:focus-visible {
  background-size: 100% 1.5px;
}

.project-page__body strong {
  color: var(--color-fg);
  font-weight: var(--fw-semibold);
}

/* Media (images, videos, iframes) inside the article are full-width and
   centered, with a subtle radius so they read as cards against the dark bg. */
.project-page__body img,
.project-page__body video,
.project-page__body iframe {
  display: block;
  width: 100%;
  height: auto;
  max-width: 100%;
  margin: 16px auto;
  border-radius: 12px;
  background: #1a1a1f;
}

.project-page__body iframe {
  aspect-ratio: 16 / 9;
}

.project-page__body .fb-post {
  margin: 16px auto;
  max-width: 100%;
}

/* Top-of-post call-to-action ("Try it in Figma", "See it Live"). Sits flush
   with the first heading — margin-bottom keeps it clear of the H1 below. */
.project-page__body .topLink {
  align-self: center;
  margin-bottom: 24px;
}

/* Any glass button rendered in the article body (CTAs from the .md) centers
   on the column — matches the H1/H2 alignment so the whole post reads as a
   centered narrative rather than left-flushed. */
.project-page__body .experiment-button {
  align-self: center;
}

/* The post-body link rule above underlines every anchor; opt anchors that
   are rendered as glass buttons out of that so the label reads as a button,
   not a link. */
.project-page__body a.experiment-button,
.project-page__body a.experiment-button:hover,
.project-page__body a.experiment-button:focus-visible {
  text-decoration: none;
}

/* Full-width media: escape the 760px article column and span the viewport.
   The centered article sits at width 760 inside a 100vw page; pulling the
   left/right margins out by (50% - 50vw) on each side lands the image at
   the viewport edges regardless of the article's current x-position. */
.project-page__body img.fullwidth,
.project-page__body video.fullwidth,
.project-page__body iframe.fullwidth {
  width: 100vw;
  max-width: 100vw;
  margin-left: calc(50% - 50vw);
  margin-right: calc(50% - 50vw);
  border-radius: 0;
}

/* Centered "Back to Work" link rendered between the article body and the
   social footer. Uppercase mono matches the H2 / topLink vocabulary so it
   reads as project-page chrome rather than another inline body link. */
.project-page__back {
  margin-top: 64px;
  text-align: center;
}

.project-page__back a {
  color: var(--color-accent);
  text-decoration: none;
  font-family: var(--font-mono);
  font-weight: var(--fw-medium);
  font-size: var(--fs-h4);
  line-height: var(--lh-h4);
  text-transform: uppercase;
  letter-spacing: var(--track-caps);
  background-image: linear-gradient(currentColor, currentColor);
  background-position: 0 calc(100% - 0.4em + 5px);
  background-size: 0 1.5px;
  background-repeat: no-repeat;
  transition: background-size 240ms ease;
  text-shadow:
    1.5px 0 var(--color-bg), -1.5px 0 var(--color-bg),
    0 1.5px var(--color-bg), 0 -1.5px var(--color-bg),
    1.5px 1.5px var(--color-bg), -1.5px -1.5px var(--color-bg),
    1.5px -1.5px var(--color-bg), -1.5px 1.5px var(--color-bg);
}

.project-page__back a:hover,
.project-page__back a:focus-visible {
  background-size: 100% 1.5px;
}

/* Footer rendered at the bottom of every project page. Same icon styles as
   the home footer, but the spacing is tighter — the project article already
   has bottom padding via .project-page, so the footer can sit flush. */
.project-page .site-footer {
  margin-top: 96px;
  padding: 32px 0 0;
}

/* ---------------------------------------------------------------------------
   View Transitions — Chrome/Edge/Safari 18.2+ animate the root crossfade
   between home and project pages automatically. The header is tagged with
   its own view-transition-name so it persists in place across navigations
   instead of fading out and back in.
   --------------------------------------------------------------------------- */

::view-transition-old(root),
::view-transition-new(root) {
  animation-duration: 280ms;
  animation-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
}

/* Hold the header still during the transition — no crossfade, no slide. */
::view-transition-old(site-header),
::view-transition-new(site-header) {
  animation: none;
}

/* Per-project named morphs: title / subtitle / media translate from the home
   card position to the project page hero (and back). Each name must be unique
   on the page at snapshot time — see the project-open suppression below. The
   button has its own name so it can fade faster than the morph duration. */
.project--orion .project__title    { view-transition-name: project-title-orion; }
.project--orion .project__subtitle { view-transition-name: project-subtitle-orion; }
.project--orion .project__media    { view-transition-name: project-media-orion; }
.project--orion .experiment-button { view-transition-name: project-button-orion; }

.project--displayglasses .project__title    { view-transition-name: project-title-displayglasses; }
.project--displayglasses .project__subtitle { view-transition-name: project-subtitle-displayglasses; }
.project--displayglasses .project__media    { view-transition-name: project-media-displayglasses; }
.project--displayglasses .experiment-button { view-transition-name: project-button-displayglasses; }

.project--photos3d .project__title    { view-transition-name: project-title-photos3d; }
.project--photos3d .project__subtitle { view-transition-name: project-subtitle-photos3d; }
.project--photos3d .project__media    { view-transition-name: project-media-photos3d; }
.project--photos3d .experiment-button { view-transition-name: project-button-photos3d; }

/* The two model canvases are named too, so the live model MORPHS from card →
   hero (the router relocates the single instance into the hero before the new
   snapshot is taken — src/photos3d.js) instead of being left behind in the
   root crossfade. Without names they fell into the root snapshot, which also
   made the title paint above BOTH halves mid-transition (the title is its own
   named group lifted above root, the model wasn't) — the group z-index rules
   below restore the bottom < title < top sandwich during the animation. */
.project--photos3d .photos3d-model--bottom { view-transition-name: photos3d-bottom; }
.project--photos3d .photos3d-model--top    { view-transition-name: photos3d-top; }

/* When a project is open, the hero holds a clone of the home card carrying
   the same view-transition-names — strip them off the original card so the
   names stay unique. (visibility:hidden on .screens isn't enough; view
   transitions snapshot by computed style, not paint.) The site-header rule
   above stays exempt since it lives outside .screens. The .photos3d-model
   canvases are stripped here too: on open the live ones relocate into the hero
   and must own the names there; the home card has none while a project is up. */
body.project-open .screens .project__title,
body.project-open .screens .project__subtitle,
body.project-open .screens .project__media,
body.project-open .screens .photos3d-model,
body.project-open .screens .experiment-button {
  view-transition-name: none;
}

/* Recreate the 3D-photos title sandwich among the view-transition groups.
   View transitions flatten the page's z-index into a fresh stack of group
   pseudo-elements, so the page's own bottom(0)/title(2)/top(3) layering is
   lost during the animation. Re-impose it on the groups: the top half stays
   above the title, the bottom half below it. (These groups only exist while a
   photos3d transition is running; the rules are inert otherwise.) */
::view-transition-group(photos3d-bottom)          { z-index: 1; }
::view-transition-group(project-media-photos3d)   { z-index: 1; }
::view-transition-group(project-subtitle-photos3d){ z-index: 2; }
::view-transition-group(project-title-photos3d)   { z-index: 2; }
::view-transition-group(photos3d-top)             { z-index: 3; }

/* Button fades fast in both directions — out on home → project, back in on
   project → home — so the CTA disappears before the title/media morph
   finishes instead of crossfading at the same pace. */
::view-transition-old(project-button-orion),
::view-transition-new(project-button-orion),
::view-transition-old(project-button-displayglasses),
::view-transition-new(project-button-displayglasses),
::view-transition-old(project-button-photos3d),
::view-transition-new(project-button-photos3d) {
  animation-duration: 120ms;
}

/* ---------------------------------------------------------------------------
   Project page hero — holds the clone of the home card (title / subtitle /
   media) at the top of the project page. Matches .screen__inner's 1048px
   centered column so the per-project grid rules (.project--orion etc.) lay
   out identically to the home card. The page's own padding already provides
   top spacing under the fixed header.
   --------------------------------------------------------------------------- */

.project-page__hero {
  width: 100%;
  max-width: var(--content-max);
  /* container-type matches .screen__inner so cqi-based title sizing in the
     clone resolves against the same width as it did on the home card. */
  container-type: inline-size;
  margin-bottom: 32px;
}

/* ---------------------------------------------------------------------------
   Skill-card custom cursor — desktop-only floating "pip" that replaces the
   OS cursor over the "A Few More Things" grid. White 48px circle with a
   color-bg-tinted icon inside: launch.svg for cards that link out,
   add.svg for cards that open a modal. JS positions it via --cursor-x/y.
   --------------------------------------------------------------------------- */

.skill-cursor {
  position: fixed;
  top: 0;
  left: 0;
  width: 48px;
  height: 48px;
  border-radius: 9999px;
  background: var(--color-fg);
  display: none;
  align-items: center;
  justify-content: center;
  pointer-events: none;
  /* Above page content but below the modal backdrop (z-index 100). */
  z-index: 90;
  /* Position is set in JS via two CSS vars (viewport coords). The trailing
     translate(-50%, -50%) centers the 48px circle on (--cursor-x, --cursor-y). */
  transform: translate(var(--cursor-x, 0), var(--cursor-y, 0)) translate(-50%, -50%);
}

.skill-cursor.is-visible {
  display: flex;
}

/* The icon glyph itself — tinted via CSS masks so the same .skill-cursor pip
   can swap between launch/add by toggling the kind modifier. mask-size pinned
   so the 24x24 SVG renders crisply at its native size inside the 48px circle. */
.skill-cursor::after {
  content: "";
  width: 24px;
  height: 24px;
  background-color: var(--color-bg);
  -webkit-mask-position: center;
          mask-position: center;
  -webkit-mask-repeat: no-repeat;
          mask-repeat: no-repeat;
  -webkit-mask-size: 24px 24px;
          mask-size: 24px 24px;
}

.skill-cursor--launch::after {
  -webkit-mask-image: url("/assets/icons/launch.svg");
          mask-image: url("/assets/icons/launch.svg");
}

.skill-cursor--add::after {
  -webkit-mask-image: url("/assets/icons/add.svg");
          mask-image: url("/assets/icons/add.svg");
}

/* Secret cards (Generative UI) show a 🤫 emoji rather than a masked icon. An
   emoji is a color glyph, so it can't ride the mask/background-color path the
   launch/add icons use — drop the mask, clear the tint, and paint the glyph
   directly as text centered in the white pip. */
.skill-cursor--secret::after {
  content: "🤫";
  width: auto;
  height: auto;
  background-color: transparent;
  -webkit-mask-image: none;
          mask-image: none;
  font-size: 26px;
  line-height: 1;
}

/* ---------------------------------------------------------------------------
   More-things modal — shares chrome with .contact-modal (48px backdrop
   padding, dark dialog with 64px radius, cursor-following X close), but the
   inner body scrolls so long markdown content fits without resizing the
   dialog. Driven by the hash router (#watchbuilding, #art, #sparkar).
   --------------------------------------------------------------------------- */

.more-modal {
  position: fixed;
  inset: 0;
  z-index: 100;
  display: flex;
  /* Whole overlay scrolls — the dialog is sized by its content and may extend
     past the viewport. align-items: flex-start pins the dialog to the top;
     the bottom simply runs off the viewport and is revealed by scrolling.
     The top padding keeps the dialog clear of the fixed site header and gives
     the modal room to breathe — but a fixed 256px eats too much of a SHORT
     viewport (iPad landscape, small/resized landscape windows), pushing the
     dialog far down the screen. Scale it with viewport height instead: tall
     desktops keep the full 256px (capped), shorter landscape viewports get
     proportionally less (~131px at 768px tall). Floored at 96px. Phones
     (≤600px) override with their own fixed 92px below. */
  align-items: flex-start;
  justify-content: center;
  padding: clamp(96px, 17vh, 256px) 48px 48px;
  background: rgba(0, 0, 0, 0.75);
  overflow-y: auto;
  /* Reserve a gutter on both sides so the centered dialog doesn't drift left
     by the scrollbar's width when the modal scrolls. */
  scrollbar-gutter: stable both-edges;
  scrollbar-width: thin;
  scrollbar-color: rgba(255, 255, 255, 0.18) transparent;
  /* Same fade+gate pattern as .contact-modal — the dialog/close button slide
     themselves; this rule fades the backdrop and gates pointer events. */
  opacity: 0;
  visibility: hidden;
  pointer-events: none;
  transition: opacity 200ms ease-in 200ms, visibility 0s linear 400ms;
}

.more-modal::-webkit-scrollbar {
  width: 8px;
}

.more-modal::-webkit-scrollbar-thumb {
  background-color: rgba(255, 255, 255, 0.18);
  border-radius: 9999px;
}

.more-modal::-webkit-scrollbar-track {
  background: transparent;
}

.more-modal.is-open {
  opacity: 1;
  visibility: visible;
  pointer-events: auto;
  transition: opacity 200ms ease-out, visibility 0s linear 0s;
}

.more-modal__dialog {
  position: relative;
  width: 100%;
  /* No fixed height — the dialog grows with content. When it exceeds the
     viewport, the .more-modal overlay handles scrolling above. The min-height
     floors it so the dialog opens at its full footprint instead of popping
     open tiny (just padding) and then jumping taller as the fetched markdown
     and its media stream in on a slow connection — every modal is taller than
     this, so the floor only governs the loading frames. */
  min-height: 600px;
  max-width: 960px;
  background: #1d1f23;
  border: 1px solid rgba(255, 255, 255, 0.06);
  border-radius: 64px;
  box-shadow: 0 24px 64px rgba(0, 0, 0, 0.5);
  display: flex;
  /* Clip children against the 64px corner radius so images can extend to the
     dialog edge without poking past the rounded corner. */
  overflow: hidden;
  /* Slide+fade — matches .contact-modal__dialog. */
  opacity: 0;
  transform: translateY(160px);
  transition: opacity 200ms ease-in 200ms, transform 400ms ease-in;
}

.more-modal.is-open .more-modal__dialog {
  opacity: 1;
  transform: translateY(0);
  transition: opacity 200ms ease-out, transform 600ms ease-out;
}

/* Close button mirrors .contact-modal__close — same fixed positioning via
   --close-x/--close-y, same snap class, same cursor-follow JS, same
   slide+fade animation. */
.more-modal__close {
  position: fixed;
  top: var(--close-y, 0);
  left: var(--close-x, 0);
  width: 32px;
  height: 32px;
  margin: -16px 0 0 -16px;
  padding: 0;
  border: 0;
  cursor: pointer;
  z-index: 2;
  background-color: var(--color-fg);
  -webkit-mask: url("/assets/icons/clear.svg") no-repeat center / 32px 32px;
          mask: url("/assets/icons/clear.svg") no-repeat center / 32px 32px;
  opacity: 0;
  transform: translateY(160px);
  transition: opacity 200ms ease-in 200ms, transform 400ms ease-in;
}

.more-modal.is-open .more-modal__close {
  opacity: 0.8;
  transform: translateY(0);
  transition: opacity 200ms ease-out, transform 600ms ease-out;
}

.more-modal.is-open .more-modal__close--snap {
  transition: opacity 200ms ease-out, transform 600ms ease-out,
              top 100ms ease-out, left 100ms ease-out;
}

.more-modal__close:focus-visible {
  outline: none;
}

.more-modal.is-cursor-following {
  cursor: none;
}

.more-modal.is-cursor-following .more-modal__close {
  pointer-events: none;
}

/* Body is a simple padded column now — the .more-modal overlay above owns
   the scrollbar, so the dialog itself just grows with its content. */
.more-modal__body {
  flex: 1;
  /* --body-pad-x mirrors the horizontal padding so .media-row--fullwidth can
     cancel it with a negative margin and bleed to the dialog edges. No top
     padding — modals have no auto title, so the first block (often a full-bleed
     media row) sits flush with the dialog top, clipped by its corner radius. */
  --body-pad-x: 48px;
  padding: 0 48px 48px;
  min-width: 0;
}

.more-modal__article {
  display: flex;
  flex-direction: column;
  gap: 24px;
  color: var(--color-fg);
}

/* Sizing/weight inherited from the UA <h2> default so this matches
   .skills__title ("A Few More Things") exactly. */
.more-modal__title {
  margin: 0;
  text-align: center;
  color: var(--color-fg);
}

.more-modal__title:empty {
  display: none;
}

/* Article body typography mirrors .project-page__body so the in-modal
   markdown reads consistently with project pages — uppercase mono H1/H2
   labels, plain paragraph text, white links with a soft underline. */
.more-modal__content {
  display: flex;
  flex-direction: column;
  gap: 24px;
  font-size: 16px;
  line-height: 1.5;
  color: var(--color-fg);
}

.more-modal__content h1 {
  margin: 0;
  padding: 24px 0 0;
  font-family: var(--font-mono);
  font-weight: var(--fw-medium);
  font-size: 48px;
  line-height: 56px;
  color: var(--color-fg);
  text-align: center;
}

/* A leading h1 (e.g. perspectivetoolkit.md) needs breathing room from the
   dialog top, since the body has no top padding to provide it. */
.more-modal__content > h1:first-child {
  margin-top: 32px;
}

.more-modal__content h2 {
  margin: 32px 0 0;
  font-family: var(--font-mono);
  font-weight: var(--fw-medium);
  font-size: 24px;
  line-height: 32px;
  text-transform: uppercase;
  letter-spacing: var(--track-caps);
  color: var(--color-fg);
  text-align: center;
}

.more-modal__content p {
  margin: 0;
  color: var(--color-secondary);
}

/* Same blue-inline-link treatment as .project-page. */
.more-modal__content p a {
  color: var(--color-accent);
  text-decoration: none;
  background-image: linear-gradient(currentColor, currentColor);
  background-position: 0 calc(100% - 0.4em + 5px);
  background-size: 0 1.5px;
  background-repeat: no-repeat;
  transition: background-size 240ms ease;
  /* Skip-ink halo — uses the dialog bg (#1d1f23), not --color-bg, because
     these links sit inside .more-modal__dialog rather than the page body. */
  text-shadow:
    1.5px 0 #1d1f23, -1.5px 0 #1d1f23,
    0 1.5px #1d1f23, 0 -1.5px #1d1f23,
    1.5px 1.5px #1d1f23, -1.5px -1.5px #1d1f23,
    1.5px -1.5px #1d1f23, -1.5px 1.5px #1d1f23;
}

.more-modal__content p a:hover,
.more-modal__content p a:focus-visible {
  background-size: 100% 1.5px;
}

.more-modal__content strong {
  color: var(--color-fg);
  font-weight: var(--fw-semibold);
}

.more-modal__content img,
.more-modal__content video,
.more-modal__content iframe {
  display: block;
  width: 100%;
  height: auto;
  max-width: 100%;
  margin: 8px auto;
  border-radius: 12px;
  background: #1a1a1f;
}

.more-modal__content iframe {
  aspect-ratio: 16 / 9;
}

/* Full-width media in the modal — the dialog is the "page" here, so bleed to
   its edges by cancelling the body's side padding (--body-pad-x) rather than
   going 100vw like the project-page version. max-width override is required:
   the base rule above caps media at max-width:100%, which would otherwise clamp
   the calc() width back to the column. The dialog's overflow:hidden clips the
   bleed to its rounded corners; border-radius:0 squares the media to match. */
.more-modal__content img.fullwidth,
.more-modal__content video.fullwidth,
.more-modal__content iframe.fullwidth {
  width: calc(100% + 2 * var(--body-pad-x, 48px));
  max-width: none;
  margin-left: calc(-1 * var(--body-pad-x, 48px));
  margin-right: calc(-1 * var(--body-pad-x, 48px));
  border-radius: 0;
}

/* ---- Media row: images/videos side by side, equal height, full width ----
   A flex row whose children each grow in proportion to their aspect ratio
   (--ar = width / height) over a flex-basis of 0. Equal basis + grow ∝ ratio
   means every child lands at the SAME rendered height while together filling
   the container width — so a 3:4 portrait and a 2:1 banner line up flush-top
   and flush-bottom. Authors set --ar per child to match the media's real
   ratio (the gap between cards is subtracted before the widths are split, so
   the heights stay equal regardless of gap). Works in both render targets:
   project pages (.project-page__body) and the "more things" modal. */
.media-row {
  display: flex;
  gap: 12px;
  align-items: flex-start;
  width: 100%;
  margin: 16px 0;
}

.media-row > * {
  flex-grow: calc(var(--ar, 1));
  flex-shrink: 1;
  flex-basis: 0;
  min-width: 0;
  /* Reserve the item's box from its authored ratio so the row holds its final
     height from first paint — before the clip's metadata (or, with
     preload="none" gating, the clip itself) has loaded. Without this the media
     starts at height 0 and snaps to size on load, reshuffling the whole modal.
     Same trick the iframe rule already uses (aspect-ratio: 16/9). Width here is
     set by flex-grow ∝ --ar, so height = width / --ar comes out equal across
     every item in the row — the "equal-height math" the row relies on, just
     resolved up front instead of after load. */
  aspect-ratio: var(--ar, auto);
}

/* Consecutive rows read as one cohesive grid: tighten the space between two
   adjacent rows to 12px so it matches the column gap. Both render targets lay
   these out in a flex column with gap:24px (margins don't collapse here), so
   zeroing a followed row's bottom margin and pulling the next row up 12px nets
   24 − 12 = 12px between them. Rows that border text keep their default 16px. */
.media-row:has(+ .media-row) {
  margin-bottom: 0;
}

.media-row + .media-row {
  margin-top: -12px;
}

/* When media is the first block in a modal, drop its top margin so it sits
   flush with the dialog top (the body has no top padding there). Covers a
   leading media-row as well as a standalone full-bleed image/video/iframe. */
.more-modal__content > .media-row:first-child,
.more-modal__content > .carousel:first-child,
.more-modal__content > img:first-child,
.more-modal__content > video:first-child,
.more-modal__content > iframe:first-child {
  margin-top: 0;
}

/* Trailing media in a modal drops its bottom margin so the body's bottom
   padding alone sets the gap to the dialog edge — no doubled-up spacing. */
.more-modal__content > .media-row:last-child,
.more-modal__content > .carousel:last-child,
.more-modal__content > img:last-child,
.more-modal__content > video:last-child,
.more-modal__content > iframe:last-child {
  margin-bottom: 0;
}

/* Reset the column-media defaults (auto margins + 8/16px stacking gap) so the
   row's own gap owns all spacing; the 12px radius / card bg from those base
   rules still apply. Scoped under both body containers to outweigh their
   `… img/video` element rules on specificity. */
.project-page__body .media-row img,
.project-page__body .media-row video,
.more-modal__content .media-row img,
.more-modal__content .media-row video {
  width: 100%;
  height: auto;
  margin: 0;
}

/* Full-bleed variant — breaks out of the column to fill its parent edge to
   edge, mirroring `img.fullwidth`. On project pages the parent is the page,
   so it spans the viewport (100vw). In the modal the parent is the dialog, so
   it cancels the body's side padding (--body-pad-x); the dialog's
   overflow:hidden then clips the bleed to its rounded corners. The row still
   keeps its 12px gap and equal-height math — only the outer bounds change. */
.project-page__body .media-row--fullwidth {
  width: 100vw;
  max-width: 100vw;
  margin-left: calc(50% - 50vw);
  margin-right: calc(50% - 50vw);
}

.more-modal__content .media-row--fullwidth {
  width: calc(100% + 2 * var(--body-pad-x, 48px));
  margin-left: calc(-1 * var(--body-pad-x, 48px));
  margin-right: calc(-1 * var(--body-pad-x, 48px));
}

/* Square off the inner media in a full-bleed row — a 12px radius floating at
   the container edge would read as a mistake rather than a deliberate bleed. */
.project-page__body .media-row--fullwidth img,
.project-page__body .media-row--fullwidth video,
.more-modal__content .media-row--fullwidth img,
.more-modal__content .media-row--fullwidth video {
  border-radius: 0;
}

/* ---- Carousel: one media item at a time in a fixed-size viewport ----
   Authors write `<div class="carousel" style="--ar: 16/9">` wrapping a flat
   list of <img>/<video>; src/carousel.js wraps those in a track + viewport and
   injects the chevron nav (so until `.is-ready` lands the slides degrade to a
   plain stacked list). The viewport owns a fixed aspect-ratio, so navigating
   only translates the track — the carousel never changes size between items. */
.carousel {
  position: relative;
  width: 100%;
  margin: 16px 0;
}

.carousel__viewport {
  position: relative;
  width: 100%;
  aspect-ratio: var(--ar, 16 / 9);
  overflow: hidden;
  border-radius: 12px;
  background: #1a1a1f;
}

.carousel__track {
  display: flex;
  height: 100%;
  transition: transform 480ms cubic-bezier(0.16, 1, 0.3, 1);
}

/* Each slide fills the viewport; cover keeps the box a clean 16:9 regardless
   of the source media's native ratio (the scoped img/video overrides below
   undo the column-media defaults — auto margins, radius, max-width). */
.project-page__body .carousel__slide,
.more-modal__content .carousel__slide {
  flex: 0 0 100%;
  width: 100%;
  height: 100%;
  margin: 0;
  border-radius: 0;
  object-fit: cover;
}

/* Chevron nav — a transparent hit target spanning the FULL height of the
   viewport (easy to hit anywhere down each edge), centered on the edge so its
   white circle straddles it (50% over the slide, 50% over the page). The circle
   is sized by the button width; translateX(±50%) does the straddle. */
.carousel__nav {
  position: absolute;
  top: 0;
  height: 100%;
  width: 40px;
  display: flex;
  align-items: center;
  justify-content: center;
  padding: 0;
  border: 0;
  background: transparent;
  cursor: pointer;
  z-index: 2;
  -webkit-tap-highlight-color: transparent;
}

.carousel__nav--prev { left: 0; transform: translateX(-50%); }
.carousel__nav--next { right: 0; transform: translateX(50%); }

/* The white circle. Its masked ::before paints the chevron glyph in the page
   background color so the icon reads as a cutout matching whatever sits behind
   the carousel (--carousel-icon-color, overridden per render target below).
   No shadow — the white disc carries the contrast on its own. Scoped under both
   body containers so it outweighs the base `… img/element` sizing rules. */
.project-page__body .carousel__chevron,
.more-modal__content .carousel__chevron {
  position: relative;
  display: flex;
  align-items: center;
  justify-content: center;
  width: 40px;
  height: 40px;
  margin: 0;
  border-radius: 50%;
  background: #fff;
}

.carousel__chevron::before {
  content: "";
  width: 24px;
  height: 24px;
  background-color: var(--carousel-icon-color, var(--color-bg));
  -webkit-mask: center / contain no-repeat;
  mask: center / contain no-repeat;
  transition: transform 320ms cubic-bezier(0.16, 1, 0.3, 1);
}

.carousel__nav--prev .carousel__chevron::before {
  -webkit-mask-image: url("/assets/icons/chevron_left.svg");
  mask-image: url("/assets/icons/chevron_left.svg");
}

.carousel__nav--next .carousel__chevron::before {
  -webkit-mask-image: url("/assets/icons/chevron_right.svg");
  mask-image: url("/assets/icons/chevron_right.svg");
}

/* Hover/focus nudges the glyph a few px toward its travel direction (ease-out)
   to hint the way it goes — the circle itself stays put. */
.carousel__nav--prev:hover .carousel__chevron::before,
.carousel__nav--prev:focus-visible .carousel__chevron::before {
  transform: translateX(-4px);
}

.carousel__nav--next:hover .carousel__chevron::before,
.carousel__nav--next:focus-visible .carousel__chevron::before {
  transform: translateX(4px);
}

/* Match the icon cutout to the modal dialog bg (the page bg only applies on
   project pages). */
.more-modal__content {
  --carousel-icon-color: #1d1f23;
}

@media (prefers-reduced-motion: reduce) {
  .carousel__track { transition: none; }
  .carousel__nav:hover .carousel__chevron::before,
  .carousel__nav:focus-visible .carousel__chevron::before { transform: none; }
}

/* Top-of-post CTA and any glass button in the markdown body center on the
   column rather than stretching full-width — matches .project-page__body. */
.more-modal__content .topLink {
  align-self: center;
  margin-bottom: 24px;
}

.more-modal__content .experiment-button {
  align-self: center;
}

.more-modal__content a.experiment-button,
.more-modal__content a.experiment-button:hover,
.more-modal__content a.experiment-button:focus-visible {
  text-decoration: none;
}

/* Tighter horizontal padding on narrow viewports, and the dialog sits closer
   to the top (92px) so it reads higher on a phone screen. */
@media (max-width: 600px) {
  .more-modal {
    padding: 92px 24px 24px;
  }
  .more-modal__dialog {
    border-radius: 32px;
    /* Lower floor on phones — the dialog is narrower so content runs taller,
       and a shorter viewport shouldn't reserve 600px of empty space. */
    min-height: 440px;
  }
  .more-modal__body {
    --body-pad-x: 24px;
    padding: 0 24px 24px;
  }
  /* Scale the markdown h1 down on phones (48/56 → 32/40). Body paragraphs
     already read 16/24 from the base rule, so no per-phone override needed. */
  .project-page__body h1,
  .more-modal__content h1 {
    font-size: 32px;
    line-height: 40px;
  }
}
