/* ==========================================================================
   site.css — Caleb van der Werff portfolio
   Theme: emerald / pixel, derived from the EliteBook wallpaper (waylandx2.png:
   emerald forest + teal lake + train with an orange stripe). Dominant sampled
   colours: #001818 #003030 #186048 #184830 #004848 — the variables below are
   tuned from those. Accent orange = the train stripe, used very sparingly.
   ========================================================================== */

:root {
  --bg-0: #030d0b;        /* page base — near-black teal            */
  --bg-1: #06181490;      /* translucent panel                      */
  --bg-2: #0a2620;        /* raised panel                           */
  --line: #1d4a3c;        /* hairline borders                       */
  --line-soft: #12332a;
  --emerald: #35b98a;     /* primary                                */
  --emerald-deep: #186048;
  --teal: #3fbfb0;        /* secondary                              */
  --foam: #dcf2e8;        /* primary text                           */
  --mist: #8fb8a8;        /* muted text                             */
  --stripe: #e8873a;      /* train-stripe orange — rare accent      */
  --font-body: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
  --font-pixel: "Silkscreen", "Press Start 2P", ui-monospace, "SF Mono", Menlo, monospace;
  --rail-w: 56px;
}

* { margin: 0; padding: 0; box-sizing: border-box; }

/* Deliberately NO background on html. body's background propagates to the
   canvas, which is what actually stops a white flash on navigation; giving
   html its own background BREAKS that propagation, so body then paints an
   opaque box of its own and buries .bg-bleed (z-index:-2) underneath it —
   which silently deleted the wallpaper from the whole site. */
html { height: 100%; }

/* ==========================================================================
   PUBLIC <-> PORTAL PAGE TRANSITION — "hard shutter"
   This block is identical in public/css/site.css and portal/css/portal.css.
   Both documents must declare it for a cross-document View Transition to run.

   The page is crushed vertically into a line at the middle of the screen,
   held there for a beat, then the destination is driven back open out of that
   line. Opacity is never touched on either page image, so every single frame
   is 100% one page or the other, divided by a hard edge. That is the whole
   point: the version this replaces cross-faded the two documents, which put a
   doubled logo, ACCESS overlapping LOG OUT, and the hero ghosting through the
   portal on screen for 235ms.

   THREE THINGS LEARNED THE HARD WAY, all of them recorded at 60fps before and
   after, and all of them non-obvious:

   1. Inside the transition, `root` is the BOTTOM layer and every *named* group
      stacks on top of it whatever z-index it had on the real page. Naming
      .bg-bleed (z-index:-2) therefore painted the wallpaper OVER the site.

   2. The root image is opaque — body's background propagates into it. So
      naming an element that sits *behind* the page does not merely reorder it,
      it cuts a hole in the page image which root then fills with flat colour.
      Naming the wallpaper made it blink out the instant the transition began.
      Hence: the backdrops deliberately stay part of the page image, and the
      only named element is the brand mark, which sits above the content.

   3. `filter` animated across a full-viewport image is far too expensive: it
      dropped 21 frames and stretched the timing. Geometry only, no filters.

   4. THE OPT-IN BELOW IS NOT ENOUGH ON ITS OWN. Chrome resolves the INCOMING
      document's @view-transition at its first rendering opportunity and does
      not reliably wait for external stylesheets, so this rule regularly
      arrived too late and the destination declined a transition the origin
      had already committed to. Both index.html files therefore carry an
      inline copy of the opt-in in <head>; the full note is there. Keep this
      rule as well — it costs nothing and documents the block — but the inline
      copy is the one that actually wins the race.
   ========================================================================== */
@view-transition { navigation: auto; }

/* The brand mark is lifted out of the page so it survives the cut: it is the
   one fixed point the eye can hold while everything else is replaced. Its
   position differs by a few px between the two documents, so it travels that
   short distance rather than being torn down and rebuilt. */
.nav-logo { view-transition-name: vt-logo; }
::view-transition-group(root)    { z-index: 0; }
::view-transition-group(vt-logo) { z-index: 1; }
::view-transition-group(vt-logo) {
  animation-duration: 720ms;
  animation-timing-function: cubic-bezier(.16, 1, .3, 1);
}
::view-transition-old(vt-logo), ::view-transition-new(vt-logo) {
  animation-duration: 160ms;
  animation-timing-function: linear;
}

/* The UA cross-fades old against new with mix-blend-mode: plus-lighter. There
   is no cross-fade here, and plus-lighter would additively blow out the seam
   where the two images meet, so it is switched off. */
::view-transition-old(root),
::view-transition-new(root) { mix-blend-mode: normal; }

::view-transition-old(root) {
  /* Smooth, and short. This was originally quantised with steps(4, end) to
     borrow the look of hand-drawn animation, which is genuinely how anime is
     timed — but on a screen that is otherwise running at 60fps, four held
     frames of 65ms read as dropped frames, not as style. Measured pacing was
     a flawless 17ms throughout; the "lag" was entirely self-inflicted. */
  animation: vt-shut 150ms cubic-bezier(.4, 0, .68, .3) both;
  transform-origin: 50% 50%;
}
::view-transition-new(root) {
  /* 150ms shut + 50ms HOLD, then driven open. Total 440ms rather than 690ms:
     this is a route the owner takes daily, so it has to feel like a cut, not
     like a cutscene. */
  animation: vt-open 240ms cubic-bezier(.16, 1, .3, 1) 200ms both;
  transform-origin: 50% 50%;
}

/* Outgoing: the page is CRUSHED, not masked. scaleY squashes the whole image
   into a line the way a CRT collapses when its signal is cut, while scaleX
   pushes outward so it reads as pressure rather than a box being resized.
   Clipping was tried first and looks wrong: it merely hides bands of the page
   and nothing appears to be under strain. Transform-only is also markedly
   cheaper than animating clip-path across a full-viewport image. */
@keyframes vt-shut {
  from { transform: scaleY(1)    scaleX(1);    }
  to   { transform: scaleY(.004) scaleX(1.05); }
}

/* 150ms..200ms is a deliberate HOLD on a dark screen — brief, but it is what
   makes the reveal land instead of merely happening. */

/* Incoming: driven back open out of the same line, overshooting a little past
   full height so it settles rather than stopping dead on the mark. */
@keyframes vt-open {
  from { transform: scaleY(.004) scaleX(1.05); }
  68%  { transform: scaleY(1.03) scaleX(1);    }
  to   { transform: scaleY(1)    scaleX(1);    }
}

/* Clicking through to the portal has to be acknowledged instantly: the browser
   then spends ~200ms fetching the next document, during which the outgoing
   page is still live and nothing else can possibly happen. Without this the
   click feels ignored, which is most of what reads as "slow". */
.access.handing-off .access-panel {
  transition: none;
  opacity: 0;
  transform: translateY(-6px) scale(.96);
}
.access.handing-off::after { opacity: 0; transition: none; }

/* ==========================================================================
   T4 — THE PUSH, RETURN LEG (privacy -> site)

   The shutter above is the arrival from /portal/. Coming back from
   /privacy.html has to be the same push that took you there, run backwards:
   privacy leaves to the RIGHT while the site arrives from the LEFT, in
   lockstep, edge to edge.

   Both arrivals are styled here because for a cross-document transition the
   whole pseudo tree belongs to the INCOMING document — so this one
   stylesheet has to serve two completely different journeys. Nothing in CSS
   can tell them apart, so js/vt-arrive.js stamps `from-privacy` on <html>
   from a parser-blocking head script, before the first rendering
   opportunity, and these rules key off it. It outranks the shutter on
   specificity, so the default arrival is untouched.

   A push that reversed itself would be wrong, incidentally: going back is
   not going forward, and matching the direction to the direction of travel
   is what keeps the two pages feeling like places rather than states.
   ========================================================================== */
html.from-privacy::view-transition-old(root) {
  animation: push-out-right 500ms cubic-bezier(.65, 0, .25, 1) both;
}
html.from-privacy::view-transition-new(root) {
  animation: push-in-left 500ms cubic-bezier(.65, 0, .25, 1) both;
}
@keyframes push-out-right { from { transform: translateX(0);     } to { transform: translateX(100%);  } }
@keyframes push-in-left   { from { transform: translateX(-100%); } to { transform: translateX(0);     } }
/* the brand mark holds still while the pages slide past — see privacy.css */
html.from-privacy::view-transition-group(vt-logo) {
  animation-duration: 500ms;
  animation-timing-function: cubic-bezier(.65, 0, .25, 1);
}

/* Reduced motion: a plain cut. Nothing collapses, nothing slides, nothing
   is held. */
@media (prefers-reduced-motion: reduce) {
  ::view-transition-group(*),
  ::view-transition-old(*),
  ::view-transition-new(*) { animation: none !important; }
}

body {
  height: 100%;
  background: var(--bg-0);
  color: var(--foam);
  font-family: var(--font-body);
  line-height: 1.6;
  overflow: hidden; /* the snap container scrolls, not the body */
}

::selection { background: var(--emerald-deep); color: var(--foam); }

/* --------------------------------------------------------------
   Wallpaper bleed — the EliteBook's actual desktop background
   (waylandx2.png, the same one visible in the noVNC screen) bleeds
   through the whole site under a dark emerald veil, so the public
   site, the portal and the remote desktop all share one backdrop.
   -------------------------------------------------------------- */
.bg-bleed {
  position: fixed;
  inset: 0;
  z-index: -2;
  background-image: url("../assets/bg-wall.jpg");
  background-size: cover;
  background-position: center;
}
.bg-bleed::after {
  /* dark veil so text stays readable; lighter at the top so the
     forest + train actually read through */
  content: "";
  position: absolute;
  inset: 0;
  background:
    linear-gradient(180deg, rgba(3, 13, 11, .72) 0%, rgba(3, 13, 11, .88) 45%, rgba(3, 13, 11, .95) 100%);
}
/* subtle scanline / dither texture over everything */
.bg-scan {
  position: fixed;
  inset: 0;
  z-index: -1;
  pointer-events: none;
  background-image: repeating-linear-gradient(
    0deg, rgba(255, 255, 255, .015) 0 1px, transparent 1px 3px);
}

/* --------------------------------------------------------------
   Snap-scroll container (love-ed: scroll snap; only that + rail)
   -------------------------------------------------------------- */
.snap {
  height: 100svh;
  overflow-y: auto;
  overflow-x: hidden;
  scroll-snap-type: y mandatory;
  scroll-behavior: smooth;
  scrollbar-width: none;
}
.snap::-webkit-scrollbar { display: none; }

.panel {
  min-height: 100svh;
  scroll-snap-align: start;
  scroll-snap-stop: always;
  display: flex;
  align-items: center;
  justify-content: center;
  padding: 96px clamp(20px, 6vw, 96px) 48px;
  position: relative;
  /* The carousel's angled neighbours are meant to bleed off the screen
     edges, but they were also inflating the scroll container's width (535px
     of content in a 390px viewport), which is what lets a phone drag the
     whole page sideways. `clip` trims them at the panel edge without
     creating a scroll box, so the depth effect survives and the stray axis
     does not. Not `hidden`: that would force the vertical axis too and
     break scroll snapping. */
  overflow-x: clip;
}
.panel-inner { width: min(1040px, 100%); }

/* phones: reclaim the generous desktop gutters so a full section fits on
   one screen instead of running under the fold of a mandatory snap panel */
@media (max-width: 640px), (max-height: 720px) {
  .panel { padding-top: 76px; padding-bottom: 32px; }
}

@media (prefers-reduced-motion: reduce) {
  .snap { scroll-behavior: auto; }
}

/* --------------------------------------------------------------
   Section transition — while a panel is not the snap target its
   content rests low and dim; snapping to it lets it settle up into
   place (transform/opacity only, compositor-friendly). Applied only
   when JS tags the body, so no-JS/reduced-motion stay static.
   -------------------------------------------------------------- */
/* The section used to settle by translating 44px AND scaling .985 while its
   children ALSO rose 22px and their children rose again: the same pixels
   moving three times at three speeds, which reads as mush rather than
   motion, and the scale softened the text for the whole transition.
   Now the parent only fades. The one directional motion in the section is
   the children rising in sequence, below. */
.sections-anim .panel .panel-inner {
  opacity: .22;
  transition: opacity .5s ease;
}
.sections-anim .panel.active .panel-inner { opacity: 1; }

/* --------------------------------------------------------------
   Left progress rail (love-ed: section indicator)
   -------------------------------------------------------------- */
.rail {
  position: fixed;
  left: 0;
  top: 0;
  bottom: 0;
  width: var(--rail-w);
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: 22px;
  z-index: 40;
}
.rail::before {
  content: "";
  position: absolute;
  top: 15%;
  bottom: 15%;
  width: 1px;
  background: var(--line-soft);
}
.rail a {
  position: relative;
  width: 12px;
  height: 12px;
}
.rail a::before {
  content: "";
  position: absolute;
  inset: 1px;
  background: var(--bg-2);
  border: 1px solid var(--line);
  /* pixel square, not a round dot */
  border-radius: 2px;
  transition: background .25s, transform .25s, border-color .25s, box-shadow .25s;
}
.rail a:hover::before { border-color: var(--emerald); }
.rail a.on::before {
  background: var(--emerald);
  border-color: var(--emerald);
  transform: rotate(45deg) scale(1.15);
  box-shadow: 0 0 12px rgba(53, 185, 138, .55);
}
.rail a .tip {
  position: absolute;
  left: 22px;
  top: 50%;
  translate: 0 -50%;
  font-family: var(--font-pixel);
  font-size: 10px;
  letter-spacing: .08em;
  text-transform: uppercase;
  color: var(--mist);
  background: var(--bg-2);
  border: 1px solid var(--line-soft);
  padding: 3px 8px;
  white-space: nowrap;
  opacity: 0;
  translate: -6px -50%;
  transition: opacity .2s, translate .2s;
  pointer-events: none;
}
.rail a:hover .tip, .rail a.on .tip { opacity: 1; translate: 0 -50%; }
@media (max-width: 760px) { .rail { display: none; } }

/* --------------------------------------------------------------
   Nav (tricks-menu-slider: menu-bar animations)
   -------------------------------------------------------------- */
.nav {
  position: fixed;
  top: 0;
  left: 0;
  right: 0;
  z-index: 50;
  display: flex;
  align-items: center;
  justify-content: space-between;
  padding: 16px clamp(20px, 5vw, 56px);
  transition: background .35s, border-color .35s, backdrop-filter .35s;
  border-bottom: 1px solid transparent;
}
.nav.scrolled {
  background: rgba(4, 16, 13, .8);
  backdrop-filter: blur(12px);
  border-bottom-color: var(--line-soft);
}
.nav-logo {
  font-family: var(--font-pixel);
  font-size: 15px;
  color: var(--foam);
  text-decoration: none;
  letter-spacing: .06em;
}
.nav-logo b { color: var(--emerald); font-weight: inherit; }
/* --------------------------------------------------------------
   Access — the nav's only job: a button that becomes the login panel.

   The motion is a container transform (Material's pattern for "this
   control turns into that surface"), tuned to this site's language and
   to two rules that separate considered motion from decoration:

     1. ASYMMETRY. Opening takes 380ms on an out-expo curve, which
        starts fast and settles slowly, so the panel arrives with
        weight. Closing takes 190ms on an ease-in, and every part
        leaves at once with no stagger. Reversing an entrance at
        entrance speed is what makes UI feel sluggish.

     2. ONE THING GROWS. The surface scales from .94 with its origin
        on the top-right corner it belongs to, so it reads as unfolding
        out of the button rather than appearing next to it. .94 rather
        than a true morph from the button's exact size, because scaling
        a 1px border by a large factor visibly thins it — at .94 the
        border renders at .94px, which nobody can see.

   A 1px connector grows from the button's underside to the panel's top
   edge during the first 140ms, stitching the two together, and the
   panel's top rule then wipes in from the same corner. Contents follow
   in sequence, 45ms apart.
   -------------------------------------------------------------- */
.access {
  position: relative;
  --access-in: cubic-bezier(.16, 1, .3, 1);   /* out-expo: decisive, settles */
  --access-out: cubic-bezier(.4, 0, 1, 1);    /* ease-in: leaves briskly */
}

/* the connector: a hairline from the button down to the panel */
.access::after {
  content: "";
  position: absolute;
  top: 100%;
  right: 18px;
  width: 1px;
  height: 10px;
  background: linear-gradient(180deg, var(--emerald), var(--emerald-deep));
  transform: scaleY(0);
  transform-origin: top;
  opacity: 0;
  pointer-events: none;
  transition: transform .19s var(--access-out), opacity .12s ease;
}
.access.open::after {
  transform: scaleY(1);
  opacity: 1;
  transition: transform .18s var(--access-in), opacity .12s ease;
}

/* the button and the panel should read as one object while open */
.access.open #access-btn {
  border-color: var(--emerald);
  box-shadow: 0 0 0 1px var(--emerald), 0 0 18px rgba(53, 185, 138, .22);
}

.access-panel {
  position: absolute;
  top: calc(100% + 10px);
  right: 0;
  width: min(340px, calc(100vw - 40px));
  background: var(--bg-2);
  border: 1px solid var(--line);
  box-shadow: 0 24px 80px rgba(0, 0, 0, .6);
  padding: 22px 22px 18px;
  overflow: hidden;              /* keeps the top rule inside the border */
  transform-origin: top right;   /* the corner it grows out of */
  transform: translateY(-10px) scale(.94);
  opacity: 0;
  visibility: hidden;            /* keeps the closed form out of the tab order */
  pointer-events: none;
  transition:
    transform .19s var(--access-out),
    opacity .14s ease,
    border-color .19s ease,
    visibility 0s .19s;
}
.access.open .access-panel {
  transform: translateY(0) scale(1);
  opacity: 1;
  visibility: visible;
  pointer-events: auto;
  border-color: var(--emerald-deep);
  transition:
    transform .38s var(--access-in),
    opacity .2s ease,
    border-color .38s ease,
    visibility 0s;
}

/* the site's signature emerald rule, wiping in from the same corner */
.access-panel::before {
  content: "";
  position: absolute;
  top: 0;
  left: 0;
  right: 0;
  height: 2px;
  background: linear-gradient(90deg, transparent, var(--teal) 40%, var(--emerald));
  transform: scaleX(0);
  transform-origin: right;
  transition: transform .16s var(--access-out);
}
.access.open .access-panel::before {
  transform: scaleX(1);
  transition: transform .5s var(--access-in) .06s;
}

/* pixel corner bracket, opposite the corner it grew from */
.access-panel::after {
  content: "";
  position: absolute;
  left: 8px;
  bottom: 8px;
  width: 11px;
  height: 11px;
  border-left: 2px solid var(--emerald-deep);
  border-bottom: 2px solid var(--emerald-deep);
  opacity: 0;
  transform: translate(4px, -4px);
  transition: opacity .12s ease, transform .16s var(--access-out);
}
.access.open .access-panel::after {
  opacity: 1;
  transform: none;
  transition: opacity .3s ease .14s, transform .42s var(--access-in) .14s;
}

.access-panel .access-title { font-size: 17px; }
.access-panel .sub { color: var(--mist); font-size: 12.5px; margin: 4px 0 16px; }

/* Contents rise in sequence behind the surface. On the way out they all
   go together, immediately — see the asymmetry note above. */
.access-panel .access-title,
.access-panel .sub,
.access-panel .field,
.access-panel .login-actions {
  opacity: 0;
  transform: translateY(8px);
  transition: opacity .12s ease, transform .18s var(--access-out);
}
.access-panel .access-title { transform: translateY(11px); }
.access.open .access-panel .access-title,
.access.open .access-panel .sub,
.access.open .access-panel .field,
.access.open .access-panel .login-actions {
  opacity: 1;
  transform: none;
  transition: opacity .26s ease, transform .44s var(--access-in);
}
.access.open .access-panel .access-title      { transition-delay: .10s; }
.access.open .access-panel .sub               { transition-delay: .145s; }
.access.open .access-panel .field:nth-of-type(1) { transition-delay: .19s; }
.access.open .access-panel .field:nth-of-type(2) { transition-delay: .235s; }
.access.open .access-panel .field:nth-of-type(3) { transition-delay: .28s; }
.access.open .access-panel .login-actions     { transition-delay: .325s; }

/* already signed in: the fields give way to a single enter button */
.access.authed .access-panel .field,
.access.authed .access-panel .sub,
.access.authed .access-panel .login-msg { display: none; }
.access-enter { display: none; width: 100%; text-align: center; }
.access.authed .access-enter { display: block; }
.access.authed .access-panel .login-actions #login-submit { display: none; }

/* Bounced here by the server rather than arriving by choice: say why.
   .login-msg is otherwise an error slot, so this is its calm variant. */
.login-msg.is-info { color: var(--teal); }
.access.bounced .access-panel { border-color: var(--emerald); }

/* Reduced motion: the panel simply exists or does not.
   `transition: none` needs !important here — every animated rule above is
   scoped under `.access.open`, which outranks a bare `.access-panel`
   selector on specificity, so a plain override silently loses and the whole
   sequence keeps playing. */
@media (prefers-reduced-motion: reduce) {
  .access::after, .access.open::after,
  .access-panel, .access.open .access-panel,
  .access-panel::before, .access.open .access-panel::before,
  .access-panel::after, .access.open .access-panel::after,
  .access-panel .access-title, .access.open .access-panel .access-title,
  .access-panel .sub, .access.open .access-panel .sub,
  .access-panel .field, .access.open .access-panel .field,
  .access-panel .login-actions, .access.open .access-panel .login-actions {
    transition: none !important;
    transition-delay: 0s !important;
  }
  .access-panel, .access.open .access-panel { transform: none !important; }
  .access-panel .access-title, .access-panel .sub,
  .access-panel .field, .access-panel .login-actions { transform: none !important; }
  .access-panel::before, .access.open .access-panel::before { transform: scaleX(1) !important; }
  .access-panel::after, .access.open .access-panel::after { transform: none !important; }
  .access::after, .access.open::after { transform: scaleY(1) !important; }
}

/* Fallback handover for browsers without cross-document View Transitions:
   an emerald wash floods out of the panel's corner to cover the screen,
   then site.js navigates to /portal/ (which arrives on the same dark). VT
   browsers never see this — they get the @view-transition animation. The
   wipe is timed to fully cover just before the ~460ms navigate in site.js. */
#portal-veil {
  position: fixed;
  inset: 0;
  z-index: 200;
  pointer-events: none;
  background: radial-gradient(circle at 92% 6%, var(--emerald-deep), var(--bg-0) 60%);
  clip-path: circle(0 at 92% 6%);
  transition: clip-path .5s cubic-bezier(.65, 0, .35, 1);
}
#portal-veil.zoom { clip-path: circle(142% at 92% 6%); pointer-events: auto; }
@media (prefers-reduced-motion: reduce) {
  #portal-veil { transition: none; }
}

.btn {
  display: inline-block;
  font-family: var(--font-pixel);
  font-size: 12px;
  letter-spacing: .08em;
  text-transform: uppercase;
  text-decoration: none;
  color: var(--foam);
  background: transparent;
  border: 1px solid var(--line);
  padding: 10px 18px;
  cursor: pointer;
  transition: border-color .25s, background .25s, box-shadow .25s, color .25s,
              transform .2s cubic-bezier(.16, 1, .3, 1);
}
.btn:hover {
  border-color: var(--emerald);
  box-shadow: 0 0 0 1px var(--emerald), 0 0 18px rgba(53, 185, 138, .25);
}
.btn-solid { background: var(--emerald-deep); border-color: var(--emerald-deep); }
.btn-solid:hover { background: var(--emerald); color: #04231a; }
/* Press: the button takes the weight instantly and springs back on the
   release curve. Asymmetric on purpose — a press that eases down feels
   like lag, and one that snaps back feels like a broken key. The whole
   `transition` is replaced rather than extended, so the colour fades pause
   for the ~80ms a press lasts, which nobody can see.
   Placed after :hover: same specificity, so source order decides. */
.btn:active { transform: translateY(1px) scale(.985); transition: transform .04s linear; }

/* --------------------------------------------------------------
   Custom cursor (tricks-menu-slider) — dot + lagging ring
   -------------------------------------------------------------- */
/* A pixel targeting reticle. The dot is a crisp emerald block that tracks
   the pointer exactly; the ring is a frame of four corner brackets (the
   site's own pixel-bracket motif, from the skill cards) that lags a few
   frames behind on a lerp. Over anything interactive the reticle LOCKS ON:
   the brackets snap inward and brighten, the block hollows to a ring. It
   reads as an instrument acquiring a target rather than a plain dot. */
.cursor-dot, .cursor-ring {
  position: fixed;
  top: 0;
  left: 0;
  z-index: 100;
  pointer-events: none;
}
/* The dot never scales (it changes width/height instead), so centring it with
   the standalone translate property is safe. The RING must not do this — see
   the note on .cursor-ring.hot below. */
.cursor-dot { translate: -50% -50%; }
/* These two are transformed on every frame the pointer moves, which is the
   textbook case for a promotion hint: it keeps each on its own compositor
   layer so a move is a composite rather than a repaint. */
.cursor-dot, .cursor-ring { will-change: transform; }
/* Every colour the reticle paints with goes through --ink and --glow. That is
   not decoration: the inversion state below has to beat .hot and .down, and
   .cursor-dot.hot.down is a three-class selector that a two-class .invert
   could never outrank. Routing the colours through custom properties means
   .invert only has to win against whoever last SET the property, which it
   does on source order, and every box-shadow keeps working untouched. */
.cursor-dot, .cursor-ring {
  --ink: var(--emerald);
  --glow: rgba(53, 185, 138, .7);
}
.cursor-dot {
  width: 7px;
  height: 7px;
  background: var(--ink);
  box-shadow: 0 0 7px var(--glow);
  /* 2026-08-03: NO transition on the lock-on. The block used to take .18s to
     hollow while the reticle separately eased its scale over ~20 frames, and
     the two together read as the cursor being slow to notice a button. The
     hollow is now the same frame as the hover. */
}
/* over a target the block opens into a hollow square that frames what you
   are about to click */
.cursor-dot.hot {
  width: 11px;
  height: 11px;
  background: transparent;
  box-shadow: inset 0 0 0 2px var(--ink), 0 0 10px var(--glow);
}
/* Held down: the block lights up. The reticle's clench is a scale, and that
   is owned by site.js in its transform string — never by a `scale` here, for
   the reason spelled out on .cursor-ring.hot below. */
.cursor-dot.down { --glow: rgba(53, 185, 138, 1); }
.cursor-dot.hot.down { box-shadow: inset 0 0 0 3px var(--ink), 0 0 15px var(--glow); }
/* the reticle: four L-shaped corner brackets painted with eight gradients,
   so it needs no extra markup. --arm is the bracket length, --w its weight. */
.cursor-ring {
  width: 30px;
  height: 30px;
  --arm: 8px;
  --w: 2px;
  --c: rgba(53, 185, 138, .85);
  background:
    linear-gradient(var(--c), var(--c)) 0 0        / var(--arm) var(--w) no-repeat,
    linear-gradient(var(--c), var(--c)) 0 0        / var(--w) var(--arm) no-repeat,
    linear-gradient(var(--c), var(--c)) 100% 0     / var(--arm) var(--w) no-repeat,
    linear-gradient(var(--c), var(--c)) 100% 0     / var(--w) var(--arm) no-repeat,
    linear-gradient(var(--c), var(--c)) 0 100%     / var(--arm) var(--w) no-repeat,
    linear-gradient(var(--c), var(--c)) 0 100%     / var(--w) var(--arm) no-repeat,
    linear-gradient(var(--c), var(--c)) 100% 100%  / var(--arm) var(--w) no-repeat,
    linear-gradient(var(--c), var(--c)) 100% 100%  / var(--w) var(--arm) no-repeat;
  /* Compose transforms from the top-left. With the default 50% 50% origin the
     browser wraps the whole transform in translate(origin)/translate(-origin),
     which re-introduces an offset that changes with the scale — measured as a
     constant 6px down-right drift while locked on. Pinning the origin makes
     the centring translate below exact at any scale. */
  transform-origin: 0 0;
  /* opacity still eases (that is the cursor appearing/leaving, not the
     lock-on); `filter` does not — the bracket glow lights on the same frame
     as the hover, same reason as the block above. */
  transition: opacity .2s ease;
}
/* Lock-on: brackets pull in and light up.
   NOTE: the shrink is NOT done here with `scale`, and must not be. CSS applies
   the individual transform properties in a fixed order — translate, rotate,
   scale, then transform — so a `scale` here would be applied BEFORE the
   transform JS writes each frame, and the position would be computed in an
   already-scaled coordinate system. Measured: the ring landed at exactly
   0.62x its coordinates, i.e. 380px up-left of the pointer at (1000,700).
   The old cursor had the same defect but also set opacity:0 on hover, so the
   ring was invisible while it jumped and nobody ever saw it. site.js now owns
   position and scale in one transform string, which cannot desynchronise. */
.cursor-ring.hot {
  --c: var(--emerald);
  --arm: 9px;
  filter: drop-shadow(0 0 5px rgba(53, 185, 138, .8));
}

/* --------------------------------------------------------------
   EMERALD ON EMERALD. Over a filled control the reticle is the colour of the
   thing beneath it and simply stops existing — measured, only 75 of its
   pixels differed from the button at all, against ~380 over the page.

   `mix-blend-mode: difference` makes each cursor pixel the inverse of
   whatever sits under THAT pixel, which is the whole trick: the part of the
   reticle hanging off the button edge inverts against the page in the same
   frame as the part on the button inverts against the emerald. No clip-path,
   no duplicated cursor, no per-component list of selectors.

   THE INK STAYS EMERALD. Painting it white first (the obvious reading of
   "invert") measured WORSE: white against #35b98a differences to a mid pink
   at 1.8:1, because inverting a mid-tone lands on another mid-tone. Emerald
   against emerald differences to BLACK — 8.4:1 — and against the near-black
   page it differences to very nearly itself, so off the button the reticle
   still looks exactly like the emerald reticle. The cursor keeps its identity
   everywhere and turns into a crisp black frame precisely where it is sitting
   on emerald. Measured over a hovered .btn-solid: 21 legible pixels before,
   186 after.

   The soft glow is dropped rather than inverted: a blurred halo run through
   difference turns to mud and blunts the hard edges the shape is read by, so
   the arms are thickened instead. site.js decides when this is on by
   comparing the real inverted result against the plain one, so a future
   emerald surface is covered without being enumerated — and a surface where
   inverting would make things worse is left alone.

   Placed AFTER .hot and .down deliberately — these overrides are all the same
   specificity as the states they replace, so source order is what carries
   them. See the note on --ink above for why that is enough.
   -------------------------------------------------------------- */
.cursor-dot.invert, .cursor-ring.invert { mix-blend-mode: difference; }
.cursor-dot.invert { --glow: transparent; }
.cursor-ring.invert {
  --c: var(--emerald);   /* full alpha: the .85 base would dilute the blend */
  --w: 3px;
  --arm: 10px;
  filter: none;
}

@media (hover: none), (pointer: coarse) {
  .cursor-dot, .cursor-ring { display: none; }
}
@media (prefers-reduced-motion: reduce) {
  .cursor-dot, .cursor-ring { transition: none; }
}
/* Every element hands its cursor over to the custom one.
   !important is doing real work here. `cursor` is inherited, so a single rule
   on the body covers most of the page — but any component that sets its own
   cursor escapes, and specificity alone is not enough to catch them:
   `.slider-dots button` is (0,1,1) and beats a bare `.has-cursor *` at (0,1,0)
   whatever the source order, so the carousel dots kept showing the OS pointer.
   Rather than chase each offender with an ever-more-specific selector, the
   handover is made absolute. Text fields are included deliberately: their
   I-beam is still the OS cursor reappearing, which is exactly the complaint. */
.has-cursor, .has-cursor * { cursor: none !important; }

/* --------------------------------------------------------------
   Click feedback. A single pixel frame snaps open from the press
   point and rotates 45° into the site's diamond as it fades — the
   same motif as the rail markers and the carousel dots, rather than
   a borrowed material ripple. It reads on touch as well as mouse,
   which the cursor's own press state cannot.

   The layer is CLIPPED on purpose: a burst is centred on the press,
   so one near the right-hand edge would otherwise overhang the
   viewport and widen the document. z-index sits just under the
   cursor (100) so the reticle is never covered by its own feedback.
   -------------------------------------------------------------- */
.click-layer {
  position: fixed;
  inset: 0;
  z-index: 99;
  pointer-events: none;
  overflow: hidden;
}
.click-burst {
  position: absolute;
  width: 14px;
  height: 14px;
  margin: -7px 0 0 -7px;   /* centred on the press point set by JS */
  border: 2px solid var(--emerald);
  animation: click-burst .42s cubic-bezier(.16, 1, .3, 1) forwards;
}
@keyframes click-burst {
  from { transform: rotate(0deg)  scale(.5);  opacity: .9; }
  to   { transform: rotate(45deg) scale(3.6); opacity: 0;  }
}
/* an emerald burst on an emerald button disappears exactly as the reticle
   did, so it takes the same per-pixel inversion — see the cursor block */
.click-burst.invert { mix-blend-mode: difference; }
@media (prefers-reduced-motion: reduce) { .click-layer { display: none; } }

/* --------------------------------------------------------------
   Type + section headings (with small emerald gem marker)
   -------------------------------------------------------------- */
.kicker {
  display: flex;
  align-items: center;
  gap: 10px;
  font-family: var(--font-pixel);
  font-size: 11px;
  letter-spacing: .18em;
  text-transform: uppercase;
  color: var(--emerald);
  margin-bottom: 18px;
}
/* the "emerald" — a small spinning gem, the seamless take on
   mysterious-emerald's divider (full-bleed version rejected as bolted-on) */
.gem {
  width: 12px;
  height: 12px;
  background: linear-gradient(135deg, var(--teal), var(--emerald) 55%, var(--emerald-deep));
  animation: gemspin 6s linear infinite;
  box-shadow: 0 0 10px rgba(63, 191, 176, .5);
}
@keyframes gemspin {
  0% { transform: rotate(45deg) scale(1); }
  50% { transform: rotate(225deg) scale(.82); }
  100% { transform: rotate(405deg) scale(1); }
}
@media (prefers-reduced-motion: reduce) { .gem { animation: none; transform: rotate(45deg); } }

h1, h2 { font-weight: 650; letter-spacing: -.01em; line-height: 1.12; }
h1 { font-size: clamp(38px, 6vw, 72px); }
h2 { font-size: clamp(26px, 3.6vw, 40px); margin-bottom: 14px; }

/* --------------------------------------------------------------
   T1 — CENTRED SECTION HEADERS, flanked by a mirrored pair of diamonds.

   The diamonds are the .gem from the kicker, promoted: the site already
   turns a square 45 degrees for the rail markers, the carousel dots and the
   favicon, so the shape is the site's own rather than an ornament borrowed
   for the occasion. That is the whole difference between this reading as
   part of the design and reading as bolted on.

   PERFECT SYMMETRY, and it is exact rather than approximate. The pair
   counter-rotate: the left runs 45deg -> 405deg, the right 45deg -> -315deg,
   so at time t they sit at 45+360t and 45-360t. A mirror about the vertical
   axis maps rotate(θ) to rotate(-θ), which would want -45-360t on the right
   — and that differs from 45-360t by exactly 90 degrees, which for a SQUARE
   is the same picture. So the two are true mirror images in every single
   frame, not merely at the ends. Same duration, same start angle, no
   negative delay needed to line them up.

   No new markup: they are ::before/::after on the heading itself, which also
   means they inherit its size. Sizing in `em` keeps the pair proportionate
   to the type at every breakpoint instead of needing their own clamp.

   The floor of the clamp is left at today's 26px on purpose. These panels
   are mandatory snap targets, and Experience and Skills already fight for
   room on a phone — the heading grows where there is space to grow and
   stays exactly as it is where there is not.
   -------------------------------------------------------------- */
/* Not the hero. It has no h2 to centre — its h1 is left-aligned inside a
   two-column grid beside the cube — so centring its kicker put PORTFOLIO
   floating in the middle of the left column with the name starting hard left
   underneath it, which reads as a mistake rather than a treatment. */
.panel:not(.hero-panel) .kicker { justify-content: center; }
/* 2026-08-03 — About is left-aligned, his call, and only About: it reads like
   the hero (kicker hard left above left-set copy), while Experience, Projects,
   What I bring and Contact stay centred. Asked explicitly before doing it, so
   the odd one out is intended — do not "fix" it into consistency. */
#about .kicker { justify-content: flex-start; }
.panel h2 {
  display: flex;
  align-items: center;
  justify-content: center;
  gap: clamp(12px, 1.8vw, 26px);
  text-align: center;
  font-size: clamp(26px, 4.2vw, 50px);
}
.panel h2::before,
.panel h2::after {
  content: "";
  flex: 0 0 auto;
  width: .40em;
  height: .40em;
  background: linear-gradient(135deg, var(--teal), var(--emerald) 55%, var(--emerald-deep));
  box-shadow: 0 0 12px rgba(63, 191, 176, .45);
  animation: gem-spin-cw 9s linear infinite;
}
.panel h2::after { animation-name: gem-spin-ccw; }
@keyframes gem-spin-cw  { from { transform: rotate(45deg);  } to { transform: rotate(405deg);  } }
@keyframes gem-spin-ccw { from { transform: rotate(45deg);  } to { transform: rotate(-315deg); } }
@media (prefers-reduced-motion: reduce) {
  .panel h2::before, .panel h2::after { animation: none; transform: rotate(45deg); }
}

/* --------------------------------------------------------------
   T2 — TITLE TREATMENT.

   On Experience, Projects and What I Bring the white heading goes. Those
   three said the same thing twice: the kicker already names the section, and
   "Work and study." under EXPERIENCE was a caption for a label. Dropping it
   removes the repetition and gives the section its space back.

   It is hidden, NOT deleted. The heading stays in the document so the page
   keeps a real outline for screen readers and search — a section whose only
   heading is a decorative <p> is a worse page, not a bolder one. The clip
   technique is used rather than display:none precisely because it keeps the
   element in the accessibility tree.

   With the heading gone the KICKER becomes the visible header on those three,
   so the T1 treatment moves to it: the gem it already carries on the left is
   mirrored on the right by the same counter-rotating pair, and every section
   still reads as a centred title between two diamonds. On the other sections
   that pair lives on the big heading; the language is the same either way.
   -------------------------------------------------------------- */
#about h2, #experience h2, #projects h2, #skills h2, .contact-panel h2 {
  position: absolute;
  width: 1px;
  height: 1px;
  margin: -1px;
  padding: 0;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
  border: 0;
}
/* 2026-08-03 — THE MIRRORED SECOND DIAMOND IS GONE. Caleb: "ive decided i
   dont like the symetry". Every section kicker now carries the single leading
   .gem it always had, matching the hero's PORTFOLIO. The counter-rotating
   ::after pair and its gemspin-ccw keyframes are deleted rather than
   commented out — the symmetry argument they existed to serve no longer
   applies, and a dead rule that looks deliberate is worse than no rule.
   Do not reinstate without asking him. */

/* 2026-08-03: About and Get in touch now hide their heading too, at Caleb's
   request, so all five sections read the same way — kicker between two
   diamonds, then straight into the copy. T2 had kept these two on the grounds
   that their headings said something the kicker did not; he looked at the
   result and wanted the consistency instead. Same clip technique, so both are
   still in the accessibility tree and both still come back on paper.

   The only title left on screen is his name in the hero, and it keeps the
   neon-emerald highlight.

   Deliberately restrained. A neon sign works because the glow is much dimmer
   than the tube: push the halo up and the letterforms stop being letterforms
   and the thing stops reading as a title, which is the failure mode Caleb
   called out. So the ink itself is untouched at full --foam, and the whole
   effect is three shadows at .30/.20/.12 alpha — enough that the type sits in
   a pool of its own colour, not enough to soften an edge. The emerald in the
   hero gets the same treatment and lights more strongly simply because the
   glow and the ink are then the same hue. */
.hero-title {
  text-shadow:
    0 0 5px  rgba(53, 185, 138, .30),
    0 0 16px rgba(53, 185, 138, .20),
    0 0 38px rgba(53, 185, 138, .12);
}
.lead { color: var(--mist); font-size: clamp(15px, 1.6vw, 18px); max-width: 56ch; }

/* --------------------------------------------------------------
   Hero
   -------------------------------------------------------------- */
.hero-panel .panel-inner {
  display: grid;
  grid-template-columns: 1.15fr .85fr;
  gap: clamp(24px, 5vw, 64px);
  align-items: center;
}
@media (max-width: 860px) {
  .hero-panel .panel-inner { grid-template-columns: 1fr; }
  .hero-cube-wrap { order: -1; height: 180px; }
}
.hero-title .accent { color: var(--emerald); }
.hero-sub { margin: 18px 0 30px; }
.hero-ctas { display: flex; gap: 14px; flex-wrap: wrap; }
.hero-loc {
  margin-top: 34px;
  font-family: var(--font-pixel);
  font-size: 11px;
  color: var(--mist);
  letter-spacing: .12em;
}

/* --------------------------------------------------------------
   Rubik's cube (maxanderson.dev element, emerald recolour)
   -------------------------------------------------------------- */
/* A real 3×3×3: 27 cubies, each with 6 faces; JS (adapted, with permission,
   from maxanderson.dev's self-solving cube) owns every transform and shades
   the emerald stickers per-frame against a fixed light. */
.hero-cube-wrap {
  display: flex;
  align-items: center;
  justify-content: center;
  height: 320px;
  perspective: 900px;
}
.cube-spin {
  transform-style: preserve-3d;
  transform: rotateX(-24deg) rotateY(-18deg);
}
.cube {
  position: relative;
  width: 0;
  height: 0;
  transform-style: preserve-3d;
}
.cube .cubie {
  position: absolute;
  left: 0;
  top: 0;
  width: 0;
  height: 0;
  transform-style: preserve-3d;
}
.cube .cubie .face {
  position: absolute;
  left: -26px;
  top: -26px;
  width: 52px;
  height: 52px;
  background: #041511;
  border: 3px solid #041511;
  border-radius: 4px;
  backface-visibility: hidden;
}
@media (max-width: 860px) {
  .hero-cube-wrap { height: 200px; perspective: 700px; }
  /* JS owns .cube-spin's transform, so the responsive shrink lives on .cube */
  .cube { transform: scale3d(.62, .62, .62); }
}

/* --------------------------------------------------------------
   About / stats
   -------------------------------------------------------------- */
.stat-row { display: flex; gap: clamp(18px, 4vw, 56px); margin-top: 34px; flex-wrap: wrap; }
.stat b {
  display: block;
  font-family: var(--font-pixel);
  /* Silkscreen's BOLD "4" has a filled-in counter and reads as a blob, so the
     stat numerals use the regular face (a <b> would default to 700) and gain
     a little size back to keep the same visual weight. */
  font-weight: 400;
  font-size: clamp(24px, 3.2vw, 38px);
  color: var(--emerald);
}
.stat span { color: var(--mist); font-size: 13px; letter-spacing: .04em; }
.trait-row { margin-top: 26px; display: flex; flex-wrap: wrap; gap: 8px; }
/* the trait chips deal themselves out left to right as the panel settles */
.sections-anim .trait-row .chip {
  opacity: 0;
  transform: translateY(6px);
  transition: opacity .45s ease, transform .5s cubic-bezier(.22, 1, .36, 1),
              border-color .3s ease, color .3s ease;
}
.sections-anim .trait-row.shown .chip { opacity: 1; transform: none; }
.sections-anim .trait-row.shown .chip:nth-child(1) { transition-delay: .04s; }
.sections-anim .trait-row.shown .chip:nth-child(2) { transition-delay: .10s; }
.sections-anim .trait-row.shown .chip:nth-child(3) { transition-delay: .16s; }
.sections-anim .trait-row.shown .chip:nth-child(4) { transition-delay: .22s; }
.sections-anim .trait-row.shown .chip:nth-child(5) { transition-delay: .28s; }
.sections-anim .trait-row.shown .chip:nth-child(6) { transition-delay: .34s; }
.sections-anim .trait-row.shown .chip:nth-child(7) { transition-delay: .40s; }
.chip:hover { border-color: var(--emerald-deep); color: var(--foam); }
@media (prefers-reduced-motion: reduce) {
  .sections-anim .trait-row .chip { opacity: 1; transform: none; transition: none; }
}

/* --------------------------------------------------------------
   Hero scroll cue — retires for good on the first scroll.
   -------------------------------------------------------------- */
.scroll-cue {
  position: absolute;
  left: 50%;
  bottom: 46px;
  translate: -50% 0;
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 8px;
  color: var(--emerald);
  opacity: .0;
  pointer-events: none;
  animation: cue-in .8s ease 1.4s forwards;
  transition: opacity .5s ease, translate .5s ease;
}
.scroll-cue-label {
  font-family: var(--font-pixel);
  font-size: 9px;
  letter-spacing: .22em;
  text-transform: uppercase;
  color: var(--mist);
}
.scroll-cue svg { animation: cue-bob 2.1s ease-in-out infinite; }
.scroll-cue.gone { opacity: 0 !important; translate: -50% 10px; animation: none; }
@keyframes cue-in { to { opacity: .85; } }
@keyframes cue-bob {
  0%, 100% { transform: translateY(0); }
  50%      { transform: translateY(5px); }
}
@media (max-width: 640px), (max-height: 720px) { .scroll-cue { bottom: 22px; } }
@media (prefers-reduced-motion: reduce) { .scroll-cue { display: none; } }
.chip {
  font-family: var(--font-pixel);
  font-size: 10px;
  letter-spacing: .1em;
  text-transform: uppercase;
  color: var(--mist);
  border: 1px solid var(--line-soft);
  background: var(--bg-1);
  padding: 6px 10px;
}

/* --------------------------------------------------------------
   Experience timeline
   -------------------------------------------------------------- */
.xp-list { margin-top: 8px; display: grid; gap: 0; }
.xp {
  display: grid;
  grid-template-columns: 150px 1fr;
  gap: 20px;
  padding: 18px 0;
  border-bottom: 1px solid var(--line-soft);
}
.xp:last-child { border-bottom: 0; }
.xp time {
  font-family: var(--font-pixel);
  font-size: 11px;
  color: var(--emerald);
  letter-spacing: .06em;
  padding-top: 4px;
}
.xp h3 { font-size: 16px; font-weight: 600; }
.xp p { color: var(--mist); font-size: 14px; margin-top: 4px; }
/* On a phone the five entries stacked at desktop sizes ran off the bottom of
   the panel, where mandatory snapping makes them awkward to reach. Tighter
   type and padding brings the whole list back inside one screen. */
@media (max-width: 640px) {
  .xp {
    grid-template-columns: 1fr;
    gap: 2px;
    padding: 12px 0;
  }
  .xp time { font-size: 10px; padding-top: 0; }
  .xp h3 { font-size: 14.5px; line-height: 1.3; }
  .xp p { font-size: 12.5px; line-height: 1.45; margin-top: 2px; }
  .xp-list { margin-top: 2px; }
  #experience h2 { margin-bottom: 8px; }
  #experience .kicker { margin-bottom: 12px; }
}

/* 320px-class phones: the five entries were still running ~19px past the
   fold, where mandatory snapping makes the tail awkward to reach */
@media (max-width: 360px) {
  .xp { padding: 8px 0; }
  .xp h3 { font-size: 13.5px; }
  .xp p { font-size: 12px; }
}

/* --------------------------------------------------------------
   Projects — 3D slider (tricks-menu-slider)
   -------------------------------------------------------------- */
.slider-stage {
  position: relative;
  height: 340px;
  margin-top: 26px;
  perspective: 1100px;
}
.slider-ring {
  position: absolute;
  inset: 0;
  transform-style: preserve-3d;
  transition: transform .85s cubic-bezier(.45, 0, .2, 1);
}
@media (prefers-reduced-motion: reduce) {
  .slider-ring { transition: none; }
}
.slide {
  position: absolute;
  left: 50%;
  top: 50%;
  width: min(340px, 62vw); /* JS keeps this in sync with the ring radius */
  margin-left: calc(min(340px, 62vw) / -2);
  margin-top: -160px;
  height: 320px;
  padding: 22px 24px 20px;
  background: linear-gradient(160deg, rgba(10, 38, 32, .97), rgba(5, 21, 17, .97));
  border: 1px solid var(--line);
  backface-visibility: hidden;
  transition: opacity .6s, border-color .6s, box-shadow .6s;
  display: flex;
  flex-direction: column;
  cursor: pointer;
  overflow: hidden; /* nothing ever leaks past the card edge */
}
/* thin emerald→teal signature line across the top of every card */
.slide::before {
  content: "";
  position: absolute;
  top: 0;
  left: 0;
  right: 0;
  height: 2px;
  background: linear-gradient(90deg, var(--emerald), var(--teal) 60%, transparent);
  opacity: .35;
  transition: opacity .6s;
}
.slide.is-front {
  border-color: var(--emerald-deep);
  box-shadow: 0 18px 60px rgba(0, 0, 0, .5), 0 0 0 1px rgba(53, 185, 138, .18), 0 0 34px rgba(53, 185, 138, .12);
}
.slide.is-front::before { opacity: 1; }
.slide.is-near { opacity: .55; }
.slide.is-far { opacity: .12; }
.slide .s-head {
  display: flex;
  align-items: center;
  justify-content: space-between;
  margin-bottom: 12px;
}
.slide .s-icon {
  font-size: 22px;
  line-height: 1;
  display: grid;
  place-items: center;
  width: 44px;
  height: 44px;
  flex: 0 0 auto;
  background: var(--bg-0);
  border: 1px solid var(--line-soft);
}
.slide.is-front .s-icon { border-color: var(--emerald-deep); }
.slide .s-meta { display: flex; align-items: center; gap: 8px; }
/* work-in-progress marker — the one card that is not finished says so.
   Uses the train-stripe orange, the palette's rare accent. */
.slide .s-wip {
  font-family: var(--font-pixel);
  font-size: 8.5px;
  letter-spacing: .1em;
  text-transform: uppercase;
  color: var(--stripe);
  border: 1px solid rgba(232, 135, 58, .45);
  background: rgba(232, 135, 58, .08);
  padding: 3px 6px;
  white-space: nowrap;
}
.slide h3 {
  font-size: 18px;
  line-height: 1.25;
  margin: 0 0 8px;
  display: -webkit-box;
  -webkit-line-clamp: 2;
  -webkit-box-orient: vertical;
  overflow: hidden;
}
/* The description takes whatever room is left and clips there. flex: 1 1 auto
   with min-height: 0 is what lets it actually shrink below its own content on
   a narrow screen — without it the text pushed the tag row past the card
   floor on phones. The line clamp is the upper bound, the flex box the lower. */
.slide p {
  color: var(--mist);
  font-size: 13.5px;
  line-height: 1.55;
  flex: 0 1 auto;   /* may shrink to fit, never stretches (a stretched
                       -webkit-box leaks one line past its own clamp) */
  min-height: 0;
  display: -webkit-box;
  -webkit-line-clamp: 6;
  -webkit-box-orient: vertical;
  overflow: hidden;
}
/* Tag row reads as the card's footer rule rather than a tray of loose chips:
   pixel text on one hairline, separated by small square dots. */
.slide .s-tags {
  display: flex;
  flex-wrap: wrap;
  gap: 0 8px;
  flex: 0 0 auto;
  margin-top: auto;   /* tags pin to the card floor whatever the copy length */
  padding-top: 12px;
  border-top: 1px solid var(--line-soft);
  transition: border-color .6s;
}
.slide.is-front .s-tags { border-top-color: var(--emerald-deep); }
.slide .s-tags span {
  position: relative;
  font-family: var(--font-pixel);
  font-size: 9px;
  line-height: 1.9;
  letter-spacing: .08em;
  text-transform: uppercase;
  color: var(--mist);
  white-space: nowrap;   /* a two-word tag never breaks across rows */
  transition: color .3s;
}
/* The separator hangs off the END of each tag rather than the start of the
   next one, so when the row wraps the new line begins with a word instead of
   a stray dot. */
.slide .s-tags span:not(:last-child)::after {
  content: "";
  display: inline-block;
  width: 3px;
  height: 3px;
  margin: 0 0 2px 8px;
  vertical-align: middle;
  background: var(--line);
}
.slide.is-front .s-tags span { color: var(--teal); }
.slide .s-year {
  font-family: var(--font-pixel);
  font-size: 11px;
  color: var(--emerald);
  letter-spacing: .08em;
}
/* Narrow screens: shorter card, tighter type, fewer description lines. The
   card is absolutely positioned and centred by margin-top, so the height and
   that offset have to move together. */
@media (max-width: 620px) {
  .slider-stage { height: 300px; }
  .slide {
    height: 284px;
    margin-top: -142px;
    padding: 18px 18px 16px;
  }
  .slide .s-icon { width: 38px; height: 38px; }
  .slide .s-head { margin-bottom: 10px; }
  .slide h3 { font-size: 17px; }
  .slide p { font-size: 13px; -webkit-line-clamp: 4; }
  /* two rows of tags at most on a phone — past that they crowd out the
     description, and the leading tags are the informative ones anyway */
  .slide .s-tags { gap: 0 7px; padding-top: 10px; max-height: 45px; overflow: hidden; }
}

/* only css/nojs.css (loaded from <noscript>) ever reveals this */
.projects-fallback { display: none; list-style: none; margin-top: 24px; }
.projects-fallback li { padding: 10px 0; border-bottom: 1px solid var(--line-soft); }
.projects-fallback li:last-child { border-bottom: 0; }
.projects-fallback b { display: block; font-size: 16px; font-weight: 600; color: var(--foam); }
.projects-fallback span {
  font-family: var(--font-pixel);
  font-size: 9px;
  letter-spacing: .08em;
  text-transform: uppercase;
  color: var(--mist);
}

.slider-ctl { display: flex; align-items: center; gap: 16px; margin-top: 20px; justify-content: center; }
.slider-ctl .btn { padding: 8px 16px; }
.slider-dots { display: flex; gap: 8px; }
.slider-dots button {
  width: 10px;
  height: 10px;
  padding: 0;
  background: var(--bg-2);
  border: 1px solid var(--line);
  border-radius: 2px;
  cursor: pointer;
  transition: background .25s, transform .25s, border-color .25s;
}
.slider-dots button:hover { border-color: var(--emerald); }
.slider-dots button.on { background: var(--emerald); border-color: var(--emerald); transform: rotate(45deg); }

/* --------------------------------------------------------------
   Skills — "What I bring". Seven cards, all of them readable at a
   glance: no hover, no click and no scroll is needed to reach any
   sentence in this section, because this is the part an employer is
   actually here to read. Hover only rewards attention (the rule
   sweeps in, the card lifts, the bracket lights) and takes nothing
   away if it never happens. Card 07 spans the row: it is the one
   claim that sets him apart from the rest of the pile.
   -------------------------------------------------------------- */
.skill-grid {
  list-style: none;
  margin-top: clamp(20px, 3vh, 30px);
  display: grid;
  grid-template-columns: repeat(3, 1fr);
  gap: 14px;
}
.sk-card {
  position: relative;
  padding: 18px 20px 20px;
  background: linear-gradient(158deg, rgba(10, 38, 32, .78), rgba(5, 21, 17, .78));
  border: 1px solid var(--line-soft);
  overflow: hidden;
  transition:
    border-color .45s cubic-bezier(.22, 1, .36, 1),
    transform .45s cubic-bezier(.22, 1, .36, 1),
    background .45s ease;
}
/* the signature emerald rule, wiped in from the left on hover — same
   gradient as the project cards, so the two sections rhyme */
.sk-card::before {
  content: "";
  position: absolute;
  top: 0;
  left: 0;
  right: 0;
  height: 2px;
  background: linear-gradient(90deg, var(--emerald), var(--teal) 70%, transparent);
  transform: scaleX(0);
  transform-origin: left;
  transition: transform .55s cubic-bezier(.22, 1, .36, 1);
}
/* pixel corner bracket, inherited from the panel this section replaced */
.sk-card::after {
  content: "";
  position: absolute;
  right: 9px;
  bottom: 9px;
  width: 11px;
  height: 11px;
  border-right: 2px solid var(--line-soft);
  border-bottom: 2px solid var(--line-soft);
  transition: border-color .45s ease;
}
.sk-card:hover, .sk-card:focus-within {
  border-color: var(--emerald-deep);
  transform: translateY(-3px);
  background: linear-gradient(158deg, rgba(13, 48, 40, .85), rgba(5, 21, 17, .8));
}
.sk-card:hover::before, .sk-card:focus-within::before { transform: scaleX(1); }
.sk-card:hover::after, .sk-card:focus-within::after { border-color: var(--emerald); }

.sk-idx {
  display: block;
  font-family: var(--font-pixel);
  font-size: 11px;
  letter-spacing: .16em;
  color: var(--emerald);
  opacity: .55;
  margin-bottom: 12px;
  transition: opacity .45s ease;
}
.sk-card:hover .sk-idx { opacity: 1; }
.sk-card h3 {
  font-family: var(--font-pixel);
  font-size: 12.5px;
  line-height: 1.5;
  letter-spacing: .04em;
  text-transform: uppercase;
  font-weight: 400;
  color: var(--foam);
  margin-bottom: 9px;
}
.sk-card p {
  color: var(--mist);
  font-size: 13.5px;
  line-height: 1.55;
}

/* 07 — full width, always lit, the paragraph set beside the label.
   The three children are placed explicitly: number over title in the
   first column, paragraph spanning both rows in the second. Left to
   auto-flow they wrap into the wrong cells (title beside the number,
   paragraph squeezed under it). */
.sk-card-lead {
  grid-column: 1 / -1;
  display: grid;
  grid-template-columns: minmax(140px, .26fr) 1fr;
  grid-template-rows: auto auto;
  gap: 0 clamp(16px, 3vw, 44px);
  align-content: center;
  padding: 22px 24px 24px;
  border-color: var(--emerald-deep);
  background: linear-gradient(115deg, rgba(14, 52, 43, .85), rgba(5, 21, 17, .78));
}
.sk-card-lead::before { transform: scaleX(1); opacity: .45; transition: opacity .55s ease; }
.sk-card-lead:hover::before { opacity: 1; }
.sk-card-lead .sk-idx { grid-area: 1 / 1; align-self: end; margin-bottom: 8px; opacity: .8; }
.sk-card-lead h3 { grid-area: 2 / 1; align-self: start; font-size: 13.5px; margin-bottom: 0; color: var(--emerald); }
.sk-card-lead p {
  grid-area: 1 / 2 / 3 / 3;
  align-self: center;
  color: var(--foam);
  font-size: clamp(14px, 1.2vw, 16px);
  line-height: 1.6;
  max-width: 74ch;
}

/* cards settle in one after another as the section snaps into place */
.sections-anim .skill-grid .sk-card {
  opacity: 0;
  transform: translateY(16px);
  transition:
    opacity .55s ease,
    transform .6s cubic-bezier(.22, 1, .36, 1),
    border-color .45s cubic-bezier(.22, 1, .36, 1),
    background .45s ease;
}
.sections-anim .skill-grid.shown .sk-card { opacity: 1; transform: none; }
.sections-anim .skill-grid.shown .sk-card:nth-child(1) { transition-delay: .04s; }
.sections-anim .skill-grid.shown .sk-card:nth-child(2) { transition-delay: .10s; }
.sections-anim .skill-grid.shown .sk-card:nth-child(3) { transition-delay: .16s; }
.sections-anim .skill-grid.shown .sk-card:nth-child(4) { transition-delay: .22s; }
.sections-anim .skill-grid.shown .sk-card:nth-child(5) { transition-delay: .28s; }
.sections-anim .skill-grid.shown .sk-card:nth-child(6) { transition-delay: .34s; }
.sections-anim .skill-grid.shown .sk-card:nth-child(7) { transition-delay: .42s; }
/* hovering must not fight the entrance transition's delay */
.sections-anim .skill-grid.shown .sk-card:hover { transition-delay: 0s; }

@media (max-width: 1000px) {
  .skill-grid { grid-template-columns: repeat(2, 1fr); }
}

/* Phones: one column of slim rows. Two narrow columns would put four
   words on a line; a stacked row keeps every sentence readable and
   still fits the section on one screen. */
@media (max-width: 640px) {
  .skill-grid { gap: 7px; margin-top: 16px; }
  .skill-grid, .sk-card-lead { grid-template-columns: 1fr; }
  .sk-card { padding: 10px 13px 11px; }
  .sk-card::after { display: none; }
  #skills h2 { margin-bottom: 8px; }
  #skills .kicker { margin-bottom: 12px; }
  .sk-idx {
    /* the number tucks up beside the title instead of owning a line */
    position: absolute;
    top: 13px;
    right: 13px;
    margin: 0;
    font-size: 10px;
  }
  .sk-card h3 { font-size: 11.5px; margin-bottom: 4px; padding-right: 30px; line-height: 1.4; }
  .sk-card p { font-size: 12.5px; line-height: 1.45; }
  /* the two-column placement above must be undone or the paragraph
     lands in an implicit second column that no longer exists */
  .sk-card-lead { gap: 0; padding: 12px 14px 13px; }
  .sk-card-lead .sk-idx, .sk-card-lead h3, .sk-card-lead p { grid-area: auto; align-self: auto; }
  .sk-card-lead h3 { margin-bottom: 5px; }
  .sk-card-lead p { font-size: 12.5px; line-height: 1.5; max-width: none; }
}

/* smallest phones and short viewports: one more notch tighter so the
   seven cards still land inside a single snap panel */
@media (max-width: 380px), (max-width: 640px) and (max-height: 800px) {
  .skill-grid { gap: 6px; margin-top: 13px; }
  .sk-card { padding: 8px 11px 9px; }
  .sk-card h3 { font-size: 11px; }
  .sk-card p, .sk-card-lead p { font-size: 11.8px; }
}

@media (prefers-reduced-motion: reduce) {
  .sk-card, .sk-card::before, .sk-card::after,
  .sections-anim .skill-grid .sk-card { transition: none; }
  .sections-anim .skill-grid .sk-card { opacity: 1; transform: none; }
  .sk-card:hover { transform: none; }
}

/* --------------------------------------------------------------
   Contact
   -------------------------------------------------------------- */
.contact-panel .panel-inner { text-align: center; }
.contact-panel .kicker { justify-content: center; }
.contact-panel .lead { margin: 0 auto; }
.contact-ctas { margin-top: 30px; display: flex; gap: 14px; justify-content: center; flex-wrap: wrap; }
.contact-phone { margin-top: 22px; font-family: var(--font-pixel); font-size: 12px; color: var(--mist); letter-spacing: .1em; }
.contact-phone a { color: inherit; text-decoration: none; transition: color .25s; }
.contact-phone a:hover, .contact-phone a:focus-visible { color: var(--emerald); }
.foot {
  position: absolute;
  bottom: 20px;
  left: 0;
  right: 0;
  padding: 0 16px;
  text-align: center;
  color: var(--mist);
  font-size: 12px;
  opacity: .75;
}
/* without this the footer links fall back to the browser's blue/purple,
   which is the one place on the page that ignored the palette */
.foot a {
  color: var(--mist);
  text-decoration-color: var(--line);
  text-underline-offset: 3px;
  transition: color .25s, text-decoration-color .25s;
}
.foot a:hover, .foot a:focus-visible {
  color: var(--emerald);
  text-decoration-color: var(--emerald);
}

/* --------------------------------------------------------------
   Login form fields (mechanism carried over from the old portal;
   the form lives inside the nav's access panel)
   -------------------------------------------------------------- */
.field { margin-bottom: 14px; }
.field label {
  display: block;
  font-family: var(--font-pixel);
  font-size: 10px;
  letter-spacing: .12em;
  text-transform: uppercase;
  color: var(--mist);
  margin-bottom: 6px;
}
.field input {
  width: 100%;
  background: var(--bg-0);
  border: 1px solid var(--line-soft);
  color: var(--foam);
  font: inherit;
  font-size: 15px;
  padding: 10px 12px;
  outline: none;
  transition: border-color .2s, box-shadow .2s;
}
.field input:focus { border-color: var(--emerald); box-shadow: 0 0 0 1px var(--emerald); }
.login-msg { min-height: 20px; font-size: 13px; color: var(--stripe); margin-bottom: 10px; }
.login-actions { display: flex; gap: 10px; }
.login-actions .btn { flex: 1; text-align: center; }
#login-form.is-shaking { animation: shake .4s; }
@keyframes shake {
  20% { translate: -8px 0; } 40% { translate: 7px 0; }
  60% { translate: -5px 0; } 80% { translate: 3px 0; }
}

/* Reveal-on-scroll. Deliberately opt-IN: the hidden state is scoped to
   .sections-anim, which only JS ever adds. If the script fails to load, is
   blocked, or the visitor has JS off, every element stays plainly visible
   instead of leaving the page blank. */
.sections-anim [data-reveal] {
  opacity: 0;
  translate: 0 14px;
  transition: opacity .5s ease, translate .55s cubic-bezier(.22, 1, .36, 1);
}
.sections-anim [data-reveal].shown { opacity: 1; translate: 0 0; }

/* --------------------------------------------------------------
   T5 — MASKED HEADING REVEAL, after labs.chaingpt.org/portfolio.

   Inspected rather than guessed at: that page carries a split-text library
   and 23 short `overflow: hidden` blocks holding children. That is the tell
   for the effect — each run of text sits inside a hard-edged box and SLIDES
   UP into it from below. There is no opacity anywhere in it. The text is not
   faint and then solid; it is absent and then present, arriving from behind
   an edge.

   That is why it was worth borrowing here specifically: this site already
   refuses soft fades everywhere else it matters — the page transition is a
   hard shutter, the privacy page is a redaction bar being pulled off, the
   cursor is a crisp block. A rising fade was the odd one out.

   Two nested spans, written by site.js:
     .rv-mask  the stationary hard edge (overflow: hidden)
     .rv-in    the text, which starts fully below that edge and rises

   The mask is an INNER box, not the heading itself, on purpose: the heading
   carries T1's diamonds as ::before/::after, and putting overflow:hidden on
   it would crop their glow. This way the mask only ever contains the words.

   Kept to headings. A four-line paragraph sliding up as one block travels
   four lines and reads as sluggish, and per-LINE splitting needs a real text
   splitter — so body copy keeps the gentler rise above, and the headings get
   the crisp arrival where it actually reads.
   -------------------------------------------------------------- */
/* T6 — one mask PER WORD, after cielrose.tv's `span span` title structure.
   Each word gets its own hard edge and its own delay, so a heading assembles
   itself left to right instead of arriving as a slab. It also removes T5's
   one real weakness: a two-line heading no longer travels two lines, because
   every word only ever travels its own height. */
.rv-w,
.rv-mask {
  display: inline-block;
  overflow: hidden;
  vertical-align: bottom;
  /* Padding pulled straight back by an equal negative margin: net zero for
     layout, but it gives the box room the clip can spend. The bottom .12em
     stops a descender being shaved as it lands, and the side .34em keeps the
     T2 neon halo from being sliced off at the mask edge. The BOTTOM edge is
     the one the effect is read from, so it stays tight to the type. */
  padding: 0 .34em .12em;
  margin: 0 -.34em -.12em;
}
.rv-in {
  display: inline-block;
  transform: translateY(110%);
  transition: transform .62s cubic-bezier(.22, 1, .36, 1);
}
.sections-anim [data-reveal].shown .rv-in,
.rv-ready .rv-in { transform: none; }

@media (prefers-reduced-motion: reduce) {
  .rv-in { transform: none; transition: none; }
}

/* --------------------------------------------------------------
   T6 — ROLLING LABELS, the other borrowing from cielrose.tv, whose links are
   built as `div { display: flex }` around `span { display: inline-block }`
   for exactly this: on hover the label leaves through the top of its own box
   while an identical copy arrives from the bottom. Nothing fades, nothing
   changes colour to announce itself — the word is simply replaced by itself.

   It is the same mask vocabulary as the heading reveal above, applied to a
   hover instead of an arrival, which is why it belongs on this site rather
   than reading as an import: the site already reveals by moving type behind
   a hard edge.

   Both copies travel together on one transform, so they cannot drift apart.
   The second copy is aria-hidden — it is the same word twice, and a screen
   reader should hear it once.
   -------------------------------------------------------------- */
.roll {
  display: inline-block;
  overflow: hidden;
  vertical-align: bottom;
  position: relative;
}
/* A SYMMETRIC curve, deliberately not the out-quint the rest of the site
   uses. Everything else here is an ARRIVAL — it comes in fast and settles,
   and a curve weighted to the end is right for that. A roll is a SWAP, and
   both copies are the same word, so the only frames that carry the effect
   are the middle ones where you can see half of each. Measured on the old
   out-quint: 40% travelled by 42ms and 93% by 180ms, which left about 40ms
   — under three frames — where anything was legible, so it read as a snap.
   This curve spends ~95ms in that middle band and still leaves the dead
   beat at the start under three frames, which is what stops a hover feeling
   like it lagged. */
.roll span {
  display: block;
  transition: transform .42s cubic-bezier(.4, 0, .2, 1);
}
.roll .roll-b {
  position: absolute;
  left: 0;
  top: 100%;
  width: 100%;
}
/* Both element types, because half the rolled labels are <button> (Access,
   Server access) and half are <a>. :focus-visible is listed with :hover so a
   keyboard reaches the same affordance a mouse does — and only
   :focus-visible, so a mouse click does not leave the label rolled after the
   pointer has gone. */
a:hover .roll span,
a:focus-visible .roll span,
button:hover .roll span,
button:focus-visible .roll span { transform: translateY(-100%); }
/* An inline-block does NOT receive text-decoration propagated from an
   ancestor — decoration crosses the box tree but stops at atomic inlines —
   so wrapping a label in .roll silently deleted the footer's underlines,
   which are the only ones on the site and are deliberate (.foot a sets its
   own colour and offset). Caught in the before/after crop, not by eye.
   Put back on the copies rather than faked as a rule on the link, so the
   underline belongs to the word and leaves with it: half a rolled label
   with the other half's underline still hanging there would be worse than
   no underline at all. */
.foot a .roll span {
  text-decoration: underline;
  text-decoration-color: var(--line);
  text-underline-offset: 3px;
  transition: transform .42s cubic-bezier(.4, 0, .2, 1), text-decoration-color .25s;
}
.foot a:hover .roll span,
.foot a:focus-visible .roll span { text-decoration-color: var(--emerald); }

@media (prefers-reduced-motion: reduce) {
  .roll span { transition: none; }
  a:hover .roll span, a:focus-visible .roll span,
  button:hover .roll span, button:focus-visible .roll span { transform: none; }
}
/* These two containers exist only to hold items that animate individually
   (the trait chips, the skill cards). They fade with the sequence but must
   not travel, or their contents would be moved twice. */
.sections-anim .trait-row[data-reveal],
.sections-anim .skill-grid[data-reveal] { translate: 0 0; }
@media (prefers-reduced-motion: reduce) {
  .sections-anim [data-reveal] { opacity: 1; translate: 0 0; transition: none; }
}

/* --------------------------------------------------------------
   Scroll train — the little two-car train from the wallpaper rides
   a track across the bottom of the viewport, its position tied to
   scroll progress (top of site = off-screen left, end = right).
   The one place the orange stripe accent gets to move.
   -------------------------------------------------------------- */
.train-track {
  position: fixed;
  left: 0;
  right: 0;
  bottom: 12px;
  height: 22px;
  z-index: 30;
  pointer-events: none;
}
.train-track::after {
  /* dashed rail under the train */
  content: "";
  position: absolute;
  left: 0;
  right: 0;
  bottom: 0;
  height: 1px;
  background: repeating-linear-gradient(90deg, var(--line) 0 10px, transparent 10px 16px);
  opacity: .5;
}
.train {
  position: absolute;
  bottom: 1px;
  left: 0;
  width: 96px;
  will-change: transform;
}
.train svg { display: block; width: 100%; height: auto; }
@media (hover: none), (pointer: coarse), (max-width: 760px), (prefers-reduced-motion: reduce) {
  .train-track { display: none; }
}

/* --------------------------------------------------------------
   Keyboard focus. The custom cursor hides the pointer, and the
   browser default ring is barely visible on this palette, so every
   interactive element gets an explicit emerald focus state. Uses
   :focus-visible so it appears for keyboard users without putting a
   ring on everything a mouse touches.
   -------------------------------------------------------------- */
:focus-visible {
  outline: 2px solid var(--emerald);
  outline-offset: 3px;
}
.btn:focus-visible {
  border-color: var(--emerald);
  box-shadow: 0 0 0 1px var(--emerald), 0 0 18px rgba(53, 185, 138, .25);
}
.rail a:focus-visible { outline: none; }
.rail a:focus-visible::before {
  border-color: var(--emerald);
  box-shadow: 0 0 0 2px rgba(53, 185, 138, .5);
}
.rail a:focus-visible .tip { opacity: 1; translate: 0 -50%; }
.slider-dots button:focus-visible { outline-offset: 4px; border-color: var(--emerald); }
/* the cards are not focus targets themselves, but a focused control
   inside one should still light it up */
.sk-card:focus-within { outline: none; }

/* --------------------------------------------------------------
   Print / save-as-PDF. Someone will hit Cmd+P on this page and file
   the result with an application, so it has to come out as a clean
   document: white paper, black text, no fixed chrome, no snap
   scrolling, every section stacked and fully expanded, and the
   project carousel flattened into a plain list.
   -------------------------------------------------------------- */
@media print {
  @page { margin: 16mm 14mm; }

  html, body {
    height: auto;
    overflow: visible;
    background: #fff;
    color: #111;
    font-size: 11pt;
    line-height: 1.45;
  }

  /* screen-only furniture */
  .bg-bleed, .bg-scan, .cursor-dot, .cursor-ring, .rail, .train-track,
  .nav, #portal-veil, .hero-cube-wrap, .slider-ctl,
  .access, .gem, .hero-ctas, .scroll-cue { display: none !important; }
  /* the contact block keeps its email, but as an address rather than a
     button. attr(href) is used instead of typing the address into the
     stylesheet, so a printed copy can never go stale. */
  .contact-ctas { display: block; margin-top: 8pt; }
  .contact-ctas button { display: none; }
  .contact-ctas a.btn {
    border: 0;
    padding: 0;
    background: none;
    font-size: 0;              /* drop the "Email me" label */
  }
  .contact-ctas a.btn::after {
    content: attr(data-print);
    font-family: var(--font-pixel);
    font-size: 9.5pt;
    letter-spacing: .06em;
    text-transform: none;
    color: #0a6b4c;
  }

  .snap { height: auto; overflow: visible; scroll-snap-type: none; display: block; }
  .panel {
    min-height: 0;
    height: auto;
    display: block;
    padding: 0 0 12pt;
  }
  /* keep an entry or a card whole, but let the sections themselves flow */
  .xp, .slide, .sk-card { page-break-inside: avoid; break-inside: avoid; }
  .panel-inner { width: 100%; opacity: 1 !important; transform: none !important; }
  /* reveal/settle states must never survive into print */
  [data-reveal], .sk-card, .sections-anim [data-reveal] {
    opacity: 1 !important;
    transform: none !important;
    translate: none !important;
  }

  .kicker { color: #0a6b4c; font-size: 8pt; letter-spacing: .12em; margin-bottom: 4pt; }
  h1 { font-size: 24pt; margin-bottom: 2pt; }
  h1 .accent { color: #0a6b4c; }
  /* a printed CV is a left-aligned document: the centred header treatment and
     its diamonds are screen furniture and do not belong on paper */
  .panel .kicker { justify-content: flex-start; }
  .panel h2 { display: block; text-align: left; }
  .panel h2::before, .panel h2::after { display: none; }
  /* every heading hidden on screen comes BACK on paper: a printed CV is read
     as a document, where "Work and study." is a section heading doing real
     work rather than a caption repeating the label above it */
  #about h2, #experience h2, #projects h2, #skills h2, .contact-panel h2 {
    position: static;
    width: auto;
    height: auto;
    margin: 10pt 0 4pt;
    overflow: visible;
    clip-path: none;
    white-space: normal;
  }
  .hero-title { text-shadow: none; }
  h2 { font-size: 14pt; margin: 10pt 0 4pt; }
  h3 { font-size: 11pt; }
  .lead, p { color: #222; max-width: none; }
  .hero-sub { margin: 4pt 0 6pt; }
  .hero-loc, .contact-phone { color: #333; font-size: 9pt; letter-spacing: .06em; }
  .contact-phone a, .foot a, a { color: #0a6b4c; }

  .stat-row { gap: 18pt; margin-top: 8pt; }
  .stat b { color: #0a6b4c; font-size: 15pt; }
  .stat span { color: #444; }
  .chip { color: #333; border-color: #bbb; background: none; font-size: 7.5pt; }

  .xp { grid-template-columns: 90pt 1fr; padding: 5pt 0; border-bottom-color: #ddd; }
  .xp time { color: #0a6b4c; font-size: 8pt; }
  .xp p { color: #333; font-size: 9.5pt; }

  /* the 3D ring cannot print: flatten it to a stacked list, first lap only */
  .slider-stage { height: auto; perspective: none; margin-top: 6pt; }
  .slider-ring { position: static; transform: none !important; display: block; }
  .slide {
    position: static;
    width: auto !important;
    height: auto;
    /* JS writes rotateY()/translateZ() straight onto style.transform, so the
       flattening has to outrank an inline style or the cards print mirrored */
    transform: none !important;
    backface-visibility: visible;
    margin: 0 0 6pt !important;
    padding: 6pt 8pt;
    background: none;
    border: 1px solid #ddd;
    box-shadow: none;
    opacity: 1 !important;
    display: block;
    page-break-inside: avoid;
    break-inside: avoid;
  }
  .slide[aria-hidden="true"] { display: none; }   /* the duplicate laps */
  .slide::before, .slide .s-icon { display: none; }
  .slide p { -webkit-line-clamp: unset; display: block; overflow: visible; color: #333; font-size: 9.5pt; }
  .slide .s-tags { border-top-color: #ddd; padding-top: 4pt; }
  .slide .s-tags span { color: #0a6b4c; font-size: 7.5pt; }
  .slide .s-year, .slide .s-wip { color: #555; }

  .skill-grid { grid-template-columns: repeat(2, 1fr); gap: 6pt; margin-top: 6pt; }
  .sk-card, .sk-card-lead {
    background: none;
    border: 1px solid #ddd;
    padding: 6pt 8pt;
    page-break-inside: avoid;
    break-inside: avoid;
  }
  .sk-card::before, .sk-card::after { display: none; }
  .sk-card-lead { grid-column: 1 / -1; display: block; }
  .sk-card-lead h3 { color: #0a6b4c; }
  .sk-idx { color: #0a6b4c; font-size: 8pt; margin-bottom: 3pt; position: static !important; }
  .sk-card h3 { color: #111; font-size: 9pt; }
  .sk-card p { color: #333; font-size: 9pt; }

  .foot { position: static; margin-top: 10pt; color: #555; font-size: 8pt; opacity: 1; }
  /* spell out where a link actually goes, since print has no hover */
  .foot a[href^="http"]::after { content: " (" attr(href) ")"; font-size: 7pt; color: #777; }
}
