/* ===========================================================
   Portfolio: minimal & chic,
   with a Stripe-style visible layout grid
   =========================================================== */

:root {
  --content-width: 1080px;
  --gutter: 28px;

  /* colors — light theme (warm off-white / cream, Stripe-ish) */
  --bg: #f5f3ee;
  --fg: #16161a;
  --muted: #4d4d54;   /* AAA (7.56:1) — owner chose the stricter tier, Jul 2026 */
  --line: rgba(20, 20, 30, 0.10);   /* grid lines: faint but readable as dashes */
  --line-strong: rgba(20, 20, 30, 0.14);
  --accent: #5b4bff;
  --accent-text: #5b4bff;   /* accent when it colours SMALL text (dark mode lightens it for AA) */
  --accent-wash: rgba(91, 75, 255, 0.10);   /* 10% indigo highlight behind a message */
  --error: #ad3826;   /* warm brick red for rejected input; dark enough for AA on its own wash */
  --error-wash: rgba(173, 56, 38, 0.10);    /* 10% of the light-mode red */
  --input-line: rgba(20, 20, 30, 0.5);      /* form-field borders: firmer than the decorative
                                               hairlines so the field's extent is perceivable (3:1) */
  --thumb-1: #e9e6ff;
  --thumb-2: #ffeede;
  --thumb-3: #e2f3ec;
  --thumb-4: #fbe4ec;

  --serif: "Fraunces", Georgia, serif;
  --sans: "Inter", system-ui, -apple-system, sans-serif;
  --mono: "JetBrains Mono", ui-monospace, "SF Mono", Menlo, monospace;
}

:root[data-theme="dark"] {
  --bg: #0d0d10;
  --fg: #f2f2f4;
  --muted: #a4a4ae;   /* AAA (7.85:1) — owner chose the stricter tier, Jul 2026 */
  --line: rgba(255, 255, 255, 0.06);
  --line-strong: rgba(255, 255, 255, 0.11);
  /* exact same blue as light mode — on the dark bg it's 3.6:1, fine for large
     text (hello world) and decorative/border bits only. Small text must use
     --accent-text: a lighter cut of the same indigo that clears AA (5.97:1). */
  --accent: #5b4bff;
  --accent-text: #8a7dff;
  --input-line: rgba(255, 255, 255, 0.35);
  /* same hues as light, but the washes need ~2x opacity: 10% over near-black
     reads far fainter than 10% over cream */
  --accent-wash: rgba(91, 75, 255, 0.20);
  --error: #ef8a7b;   /* lighter coral: a light-mode red goes muddy on near-black */
  --error-wash: rgba(239, 138, 123, 0.15);
  --thumb-1: #1c1a33;
  --thumb-2: #2a2018;
  --thumb-3: #16241d;
  --thumb-4: #2a1820;
}

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

/* el.hidden must always win: display rules on .form etc. would otherwise
   override the UA's [hidden] and keep "hidden" forms on screen (the unlock
   states and the say-hi thank-you rely on this). */
[hidden] { display: none !important; }

/* keyboard focus: one branded ring instead of the UA default. :focus-visible
   keeps it away from mouse clicks (text fields count as keyboard-focused per
   spec, so they always ring — intended). --accent-text keeps it AA in dark. */
a:focus-visible,
button:focus-visible,
summary:focus-visible,
input:focus-visible,
textarea:focus-visible,
[role="button"]:focus-visible {
  outline: 2px solid var(--accent-text);
  outline-offset: 3px;
}

/* keyboard-only "skip to content": parked above the viewport, slides in on
   focus (styled like the primary button so it reads as part of the family) */
.skiplink {
  position: fixed;
  left: var(--gutter);
  top: -60px;
  z-index: 60;               /* above the nav (20) and its overlay */
  background: var(--fg);
  color: var(--bg);
  border-radius: 999px;
  padding: 9px 18px;
  font-size: 13px;
  font-weight: 500;
  text-decoration: none;
  transition: top .15s ease;
}
.skiplink:focus-visible { top: 14px; }

/* overflow-x clamp lives here on <html> as `clip` (not `hidden`): clip prevents the
   full-bleed grid lines from causing a horizontal scrollbar without turning the
   element into a scroll container, which would silently break scroll-behavior: smooth
   and all anchor / "See me as a list" autoscrolling. */
html { scroll-behavior: smooth; overflow-x: clip; }

body {
  font-family: var(--sans);
  background: var(--bg);
  color: var(--fg);
  line-height: 1.6;
  -webkit-font-smoothing: antialiased;
  transition: background .4s ease, color .4s ease;
}

/* headings: tighter than body text so wrapped lines sit close together */
h1, h2, h3 { line-height: 1.25; }

/* ----------------------------------------------------------
   VISIBLE LAYOUT GRID  (fixed skeleton behind the content)
   ---------------------------------------------------------- */
.guides {
  position: fixed;
  inset: 0;
  z-index: 0;
  display: flex;
  justify-content: center;
  pointer-events: none;
}
.guides__inner {
  position: relative;
  width: 100%;
  max-width: var(--content-width);
  height: 100%;
}
/* each vertical line is its own element, a true dashed line */
.guide-line {
  position: absolute;
  top: 0;
  bottom: 0;
  width: 0;
  transform: translateX(-0.5px);
  border-left: 1px dashed var(--line);
}
/* outer (edge) lines a little more visible */
.guide-line:first-child,
.guide-line:last-child {
  border-left-color: var(--line-strong);
}
/* full-bleed edge lines, flush to the far left/right of the viewport.
   At exactly 0/max px the window edge swallows the line, so we nudge it
   1px inward and match the strength of the inner content lines. */
.guide-edge {
  position: absolute;
  top: 0;
  bottom: 0;
  width: 0;
  border-left: 1px dashed var(--line-strong);
}
.guide-edge--left { left: 1px; }
.guide-edge--right { right: 1px; }
/* on narrow screens hide the inner lines, keep only the edges */
@media (max-width: 720px) {
  .guide-line:not(:first-child):not(:last-child) { display: none; }
}
/* phones + portrait tablets: a 1px-from-the-edge line is invisible on real
   device screens (rounded corners / edge clipping), so pull the frame lines
   visibly inward; the content's 0%/100% lines would hug the same clipped edge,
   so they hand over to the inset frame here */
@media (max-width: 860px) {
  .guide-edge--left { left: 12px; }
  .guide-edge--right { right: 12px; }
  .guide-line:first-child,
  .guide-line:last-child { display: none; }
}

/* all content sits above the grid */
.nav, main, .footer { position: relative; z-index: 1; }

/* shared content width, so edges line up with the grid */
.nav,
.hero,
.pagehead,
.section__head,
.projects,
.about__grid,
.contact__inner,
.how,
.entries,
.nextwrap,
.footer {
  max-width: var(--content-width);
  margin-inline: auto;
  padding-inline: var(--gutter);
}

/* "key expired" reminder bar under the header (main.js injects it on the
   non-case pages when a visitor's key has lapsed). Full-bleed indigo wash,
   text aligned to the content column. */
.keybar {
  position: relative;
  z-index: 1;
  width: 100%;                 /* fill the flex column on the single-screen home */
  background: var(--accent-wash);
}
.keybar__inner {
  max-width: var(--content-width);
  margin-inline: auto;
  padding: 9px var(--gutter);
  font-size: 13px;
  color: var(--fg);
}
.keybar__cta {
  color: var(--fg);
  font-weight: 600;
  text-decoration: underline;
  text-underline-offset: 2px;
  white-space: nowrap;
}
.keybar__cta:hover { color: var(--accent-text); }
@media (max-width: 640px) {
  .keybar__long { display: none; }   /* "to see case studies" drops on phones */
}

/* ----------------------------------------------------------
   NAV
   ---------------------------------------------------------- */
.nav {
  position: relative;
  padding-top: 26px;
  padding-bottom: 26px;
  padding-inline: 0;   /* override the content gutter so columns sit on the grid lines */
  z-index: 20;         /* above main content so the mobile menu panel overlays it */
}
/* the four links: one per grid column on desktop, dropdown panel on small screens */
.nav__links {
  display: grid;
  grid-template-columns: repeat(4, 1fr);
  align-items: center;
}
/* Stripe-style dashed divider under the header — spans the FULL viewport width
   so it meets the outer vertical edge lines (no floating in the middle). */
.nav::after {
  content: "";
  position: absolute;
  bottom: 0;
  left: 50%;
  transform: translateX(-50%);
  width: 100vw;
  border-bottom: 1px dashed var(--line-strong);
}
/* nav items — one per grid column, left-aligned to each column line */
.nav__link {
  position: relative;
  padding-left: var(--gutter);
  font-size: 14px;
  color: var(--muted);
  text-decoration: none;
  transition: color .2s;
}
.nav__link:hover { color: var(--fg); }
.nav__link.is-current { color: var(--fg); font-weight: 600; }
/* the asterisk marks the current page, sitting just below it */
.nav__link.is-current::after {
  /* \FE0E = text-presentation selector: stops iOS from swapping the asterisk
     for its emoji version, so it stays a glyph and takes the accent color */
  content: "✳\FE0E";
  content: "✳\FE0E" / "";   /* silent for screen readers (alt-text syntax) */
  position: absolute;
  left: var(--gutter);
  top: 100%;
  margin-top: 4px;
  font-size: 0.9em;
  line-height: 1;
  color: var(--accent);
}
/* gentle lined lock — case studies are gated, so "not everyone gets in" */
.nav__lock {
  width: 12px;
  height: 12px;
  margin-left: 6px;
  vertical-align: -2px;
  opacity: 0.55;
  display: none;   /* temporarily hidden by request; remove this line to show the lock again */
}
.nav__toggle {
  position: absolute;
  right: var(--gutter);   /* same inset from the right line as Home has from the left */
  top: 50%;
  transform: translateY(-50%);
  background: none;
  border: 1px solid var(--line-strong);
  color: var(--fg);
  width: 36px; height: 36px;
  border-radius: 999px;
  cursor: pointer;
  font-size: 15px;
  display: grid; place-items: center;
  transition: border-color .2s;
}

/* hamburger — hidden on desktop; appears ≤860px (phones + portrait tablets).
   Two calm lines that fold into an ✕ while the menu is open. */
.nav__burger {
  display: none;
  width: 40px; height: 40px;
  padding: 0;
  background: none;
  border: 1px solid var(--line-strong);
  border-radius: 999px;
  cursor: pointer;
  place-content: center;
  gap: 5px;
}
.nav__burger span {
  display: block;
  width: 15px; height: 1.5px;
  background: var(--fg);
  transition: transform .25s ease;
}
.nav--open .nav__burger span:first-child { transform: translateY(3.25px) rotate(45deg); }
.nav--open .nav__burger span:last-child { transform: translateY(-3.25px) rotate(-45deg); }

@media (max-width: 860px) {
  .nav {
    display: flex;
    align-items: center;
    padding-block: 14px;
    padding-inline: var(--gutter);
  }
  /* keep the burger painting above the fixed overlay so ✕ stays tappable;
     margin-left:auto pins it right whether or not a back link sits on the left */
  .nav__burger { display: grid; position: relative; margin-left: auto; }
  /* the page's back link (moved here by main.js) sits left, aligned with the burger */
  .nav > .backlink,
  .nav > .case__back {
    margin: 0;
    font-size: 14px;
  }
  /* links open as a full-page overlay (body scroll locked via .nav-locked) */
  .nav__links { display: none; }
  .nav--open .nav__links {
    display: flex;           /* column so the socials can pin to the bottom */
    flex-direction: column;
    align-items: stretch;
    position: fixed;
    inset: 0;
    background: var(--bg);
    padding: 88px 0 24px;   /* clear the header row; rows carry their own gutter */
    overflow-y: auto;
  }
  .nav__links .nav__link {
    display: block;
    padding: 16px var(--gutter);
    font-size: 19px;
  }
  .nav__links .nav__link + .nav__link { border-top: 1px dashed var(--line); }
  /* in the overlay the current-page ✳ leads the label: "✳ Home" */
  .nav__link.is-current::after { content: none; }
  .nav__link.is-current::before {
    content: "✳\FE0E";
    content: "✳\FE0E" / "";
    font-size: 0.9em;
    line-height: 1;
    color: var(--accent);
    margin-right: 8px;
  }
  /* the theme toggle lives inside the panel on small screens (moved by main.js) */
  .nav__links .nav__toggle {
    position: static;
    transform: none;
    margin: 18px var(--gutter) 4px;
  }
  /* set by main.js while the overlay menu is open */
  body.nav-locked { overflow: hidden; }
}
.nav__toggle:hover { border-color: var(--fg); }

/* ----------------------------------------------------------
   HERO
   ---------------------------------------------------------- */
.hero {
  padding-top: 96px;
  padding-bottom: 120px;
}
.hero__eyebrow {
  font-size: 13px;
  letter-spacing: .14em;
  text-transform: uppercase;
  color: var(--muted);
  margin-bottom: 26px;
}
.hero__title {
  font-family: var(--serif);
  font-weight: 400;
  font-size: clamp(34px, 6vw, 68px);
  line-height: 1.08;
  letter-spacing: -0.02em;
  max-width: 16ch;
}
.hero__title em {
  font-style: italic;
  color: var(--accent);
}
.hero__lede {
  margin-top: 28px;
  font-size: clamp(16px, 2.2vw, 19px);
  color: var(--muted);
  max-width: 52ch;
}
/* landing lede — wider (~3 of the 4 grid columns) so the second line
   doesn't drop its last word; the line break stays intentional. */
.hero--landing .hero__lede { max-width: 56rem; }

/* landing buttons — equal width, balanced, calm */
.hero--landing .hero__actions .btn {
  min-width: 196px;
  justify-content: center;
}
.hero__actions {
  margin-top: 40px;
  display: flex;
  gap: 14px;
  flex-wrap: wrap;
}
.btn {
  display: inline-flex;
  align-items: center;
  padding: 13px 24px;
  border-radius: 999px;
  font-size: 15px;
  font-weight: 500;
  text-decoration: none;
  transition: transform .2s, background .2s, border-color .2s, color .2s;
  /* real <button>s (Say hi form) must not carry the platform's native button
     look — iOS especially restyles them; strip it so mobile matches desktop */
  appearance: none;
  -webkit-appearance: none;
  background: none;
  border: 0;
  font-family: inherit;
  cursor: pointer;
}
.btn--primary { background: var(--fg); color: var(--bg); }
.btn--primary:hover { transform: translateY(-2px); }
.btn--ghost { border: 1px solid var(--line-strong); color: var(--fg); }
.btn--ghost:hover { border-color: var(--accent); color: var(--fg); font-style: italic; }
.btn:disabled { opacity: 0.62; cursor: default; font-style: normal; }
/* …and the expired state's button becomes a quiet status line instead of a
   dead control (Option C): no border, muted words, full opacity */
.btn--waiting:disabled {
  opacity: 1;
  border-color: transparent;
  color: var(--muted);
  padding-left: 0;
}
.btn { transition: opacity .25s ease; }

/* ----------------------------------------------------------
   SECTION (shared)
   ---------------------------------------------------------- */
.section { padding-block: 80px; }
.section__head {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  margin-bottom: 44px;
}
.section__title {
  font-family: var(--serif);
  font-weight: 400;
  font-size: clamp(26px, 4vw, 40px);
  letter-spacing: -0.01em;
}
.section__sub { color: var(--muted); font-size: 14px; }

/* ----------------------------------------------------------
   WORK / CASE CARDS
   ---------------------------------------------------------- */
.projects {
  display: grid;
  grid-template-columns: repeat(2, 1fr);
  gap: 2px;                 /* a hairline feel: thin divider between cards */
}
.project {
  position: relative;
  display: block;
  padding: 22px;
  text-decoration: none;
  color: var(--fg);
  border: 1px solid transparent;
  border-radius: 14px;
  transition: background .25s, transform .25s;
}
.project:hover { background: color-mix(in srgb, var(--fg) 4%, transparent); }
.project:hover .project__thumb { transform: scale(1.015); }
.project:hover .project__arrow { opacity: 1; transform: translate(0, 0); }
.project__thumb {
  aspect-ratio: 4 / 3;
  border-radius: 10px;
  background: var(--thumb-1);
  transition: transform .35s ease;
  overflow: hidden;
}
.project__thumb img {
  display: block;
  width: 100%;
  height: 100%;
  object-fit: cover;
}
.project__thumb[data-tone="2"] { background: var(--thumb-2); }
.project__thumb[data-tone="3"] { background: var(--thumb-3); }
.project__thumb[data-tone="4"] { background: var(--thumb-4); }
.project__meta { margin-top: 20px; }
.project__meta h2 {
  font-family: var(--serif);
  font-weight: 500;
  font-size: 21px;
  letter-spacing: -0.01em;
}
.project__meta p { color: var(--muted); font-size: 14px; margin-top: 4px; }
.project__arrow {
  position: absolute;
  top: 30px; right: 30px;
  font-size: 18px;
  opacity: 0;
  transform: translate(-4px, 4px);
  transition: opacity .25s, transform .25s;
}

/* ---- list layout for the case studies page (stacked horizontal cards) ---- */
.projects--list {
  display: block;
  border-top: 1px dashed var(--line-strong);
}
.projects--list .project {
  display: grid;
  grid-template-columns: 1fr auto;
  align-items: center;
  column-gap: 40px;
  padding: 28px 0;
  border-radius: 0;
  border-bottom: 1px dashed var(--line-strong);
}
.projects--list .project:hover { background: transparent; }
.projects--list .project:hover h2 { color: var(--accent-text); }
/* Thumbnails hidden by owner's request until asked back; image picks live in work.html.
   To bring them back: remove the display:none and restore the 190px column above. */
.projects--list .project__thumb {
  display: none;
  aspect-ratio: 16 / 10;
  border-radius: 10px;
}
.projects--list .project__meta { margin-top: 0; }
.projects--list .project__meta h2 {
  font-size: clamp(20px, 2.6vw, 27px);
  transition: color .2s;
}
.projects--list .project__meta p { font-size: 15px; }
.projects--list .project__arrow {
  position: static;
  opacity: 0.4;
  transform: none;
  font-size: 20px;
}
.projects--list .project:hover .project__arrow {
  opacity: 1;
  transform: translateX(5px);
  color: var(--accent);
}
@media (max-width: 640px) {
  .projects--list .project { grid-template-columns: 1fr; row-gap: 16px; padding: 22px 0; }
  .projects--list .project__arrow { display: none; }
}

/* ----------------------------------------------------------
   ABOUT
   ---------------------------------------------------------- */
.about__grid {
  display: grid;
  grid-template-columns: 1.6fr 1fr;
  gap: 60px;
}
.about__solo {
  max-width: var(--content-width);
  margin-inline: auto;
  padding-inline: var(--gutter);
}
/* when jumped to via the "modes" pills, both titles should land the same
   distance below the pills. At #words the bar sits in flow just above the
   section; at #list it's stuck at the top (bottom edge ≈ 57px), so #list
   compensates to match the 28px title gap. */
#words { scroll-margin-top: 78px; }
#list { scroll-margin-top: 57px; }
.about__text p { margin-top: 18px; max-width: 46ch; color: var(--muted); }
/* narrative spans ~three grid columns, stopping short of the 75% guide line to breathe */
.about__solo .about__text { max-width: calc(var(--content-width) * 0.75 - var(--gutter) - 2.5rem); }
.about__solo .about__text p { max-width: none; }
.about__text p:first-of-type { margin-top: 24px; }
.about__text strong { color: var(--fg); font-weight: 600; }
.about__side h3 {
  font-size: 13px;
  letter-spacing: .12em;
  text-transform: uppercase;
  color: var(--muted);
  margin-bottom: 12px;
}
.about__side h3:not(:first-child) { margin-top: 30px; }
.about__side ul { list-style: none; }
.about__side li {
  padding: 8px 0;
  border-bottom: 1px solid var(--line);
  font-size: 15px;
}

/* ----------------------------------------------------------
   CONTACT
   ---------------------------------------------------------- */
.contact { text-align: center; padding-block: 110px; }
/* keep the closing CTA to ~3 grid columns, centered in the page */
.contact__inner { max-width: 50rem; }
.contact__title {
  font-family: var(--serif);
  font-weight: 400;
  font-size: clamp(20px, 2.6vw, 28px);
  line-height: 1.25;
  letter-spacing: -0.01em;
}
.contact__mail {
  display: inline-block;
  margin-top: 32px;
  font-size: clamp(18px, 3vw, 26px);
  color: var(--accent-text);
  text-decoration: none;
  border-bottom: 1px solid transparent;
  transition: border-color .2s;
}
.contact__mail:hover { border-color: var(--accent); }
.contact__socials {
  margin-top: 40px;
  display: flex;
  gap: 26px;
  justify-content: center;
  flex-wrap: wrap;
}
.contact__socials a {
  color: var(--muted);
  text-decoration: none;
  font-size: 14px;
  transition: color .2s;
}
.contact__socials a:hover { color: var(--fg); }

/* ----------------------------------------------------------
   FOOTER
   ---------------------------------------------------------- */
.footer {
  display: flex;
  justify-content: space-between;
  align-items: center;
  padding-block: 36px;
  color: var(--muted);
  font-size: 13px;
}
/* footer top divider — also full viewport width, matching the header */
.footer::before {
  content: "";
  position: absolute;
  top: 0;
  left: 50%;
  transform: translateX(-50%);
  width: 100vw;
  border-top: 1px dashed var(--line-strong);
}
.footer a { color: var(--muted); text-decoration: none; }
.footer a:hover { color: var(--fg); }

/* Short pages: pin the footer to (at least) the bottom of the viewport instead
   of letting it ride up mid-screen. Same trick body.home and body.unlock were
   already using, promoted to every page that has a footer; the :has() scope
   leaves the footerless pages (k-loading, dev-login, admin) untouched. */
body:has(> .footer) { display: flex; flex-direction: column; min-height: 100vh; min-height: 100dvh; }
/* flex items with auto side-margins shrink to content; force full width so the
   nav/footer keep spanning the content column (same fix as body.home) */
body:has(> .footer) > .nav,
body:has(> .footer) > main,
body:has(> .footer) > .footer { width: 100%; }
body:has(> .footer) > main { flex: 1 0 auto; }

/* footer with a confidentiality note above the © / back-to-top row */
.footer--legal { flex-direction: column; align-items: stretch; gap: 14px; }
.footer__note { font-size: 13px; color: var(--muted); }
.footer__row { display: flex; justify-content: space-between; align-items: center; }

/* ----------------------------------------------------------
   HOME — single-screen (above-the-fold) landing
   ---------------------------------------------------------- */
/* 100dvh (with vh fallback): on phones/tablets 100vh includes the space behind
   the browser's address bar, which forces a scroll; dvh tracks the real visible
   height so the single-screen home truly fits without scrolling */
body.home { display: flex; flex-direction: column; min-height: 100vh; min-height: 100dvh; }
/* flex items with auto side-margins shrink to content; force full width so the
   nav/footer keep spanning the content column (with internal space-between). */
body.home > .nav,
body.home > main,
body.home > .footer { width: 100%; }
body.home main { flex: 1 0 auto; display: flex; align-items: center; }
.hero--landing { width: 100%; padding-top: 24px; padding-bottom: 24px; }

/* "Hello world" in a code / terminal font, with a blinking caret */
.greeting {
  display: block;
  font-family: "JetBrains Mono", ui-monospace, "SF Mono", Menlo, monospace;
  font-weight: 500;
  font-size: clamp(34px, 6.5vw, 68px);
  letter-spacing: -0.02em;
  line-height: 1.05;
  color: var(--accent);
  margin-bottom: clamp(22px, 3.2vh, 34px);
  /* optical alignment: pull the block left by the mono glyph's left side-bearing
     so "hello" lines up with the paragraph/buttons below it (scales with size) */
  margin-left: -0.05em;
  /* keep "hello world" (and the period + caret after it) on one line — otherwise
     the trailing caret box can wrap to a phantom second line on narrow phones,
     opening a gap below the greeting */
  white-space: nowrap;
}
.caret {
  display: inline-block;
  width: 0.55ch;
  height: 1.05em;
  margin-left: 3px;
  transform: translateY(3px);
  background: var(--accent);
  animation: blink 1.1s steps(1) infinite;
  cursor: pointer;   /* click it to "finish" the greeting with a period */
}
@keyframes blink { 50% { opacity: 0; } }
@media (prefers-reduced-motion: reduce) { .caret { animation: none; } }
/* caret hidden after click — box kept (visibility, not display) so no line shift */
.caret--hidden { visibility: hidden; animation: none; }
/* the period that replaces the caret on click. It's a full monospace cell with
   the "." centred inside, so pull it left to sit snug against "world" instead of
   floating with a gap ("world ." → "world."). */
.greeting__dot { animation: dotIn .25s ease both; margin-left: -0.2em; }
@keyframes dotIn {
  from { opacity: 0; transform: translateY(-3px); }
  to   { opacity: 1; transform: none; }
}
/* On phones the greeting's 6.5vw never reaches, so it sat at the 34px floor and
   felt small. Scale it up (and a touch bolder) to fill the width and echo the
   desktop impact, while staying on ONE line so nothing is pushed below the fold. */
/* small screens incl. portrait tablets: bigger, bolder greeting, tighter gaps
   (site rule: portrait tablets follow mobile, landscape tablets follow desktop) */
@media (max-width: 860px) {
  .greeting {
    /* 68px cap lets portrait tablets reach the full desktop size; phones
       (≤~460px) stay in the 40–55px range via the 12vw slope */
    font-size: clamp(40px, 12vw, 68px);
    font-weight: 600;
    margin-bottom: 14px;   /* both this and the lede's top margin must drop —
                              they collapse, so the gap is the larger of the two */
  }
  .hero--landing .hero__lede { margin-top: 14px; }
  /* slim the home footer so the divider + © settle near the bottom edge of the
     one-screen layout (safe-area keeps it clear of the home indicator) */
  body.home .footer {
    padding-top: 18px;
    padding-bottom: calc(14px + env(safe-area-inset-bottom, 0px));
  }
}
/* portrait tablets only: the hero lede stops short of the 75% column line,
   spanning ~three of the four grid columns like on desktop (phones keep full
   width — the guides' inner lines are visible in this range, so the text
   visibly respects the column) */
@media (min-width: 641px) and (max-width: 860px) {
  .hero--landing .hero__lede { max-width: calc(75vw - var(--gutter) - 1rem); }
}
/* phones only (explicit exception to the rule above): full-width stacked CTAs */
@media (max-width: 640px) {
  .hero--landing .hero__actions { flex-direction: column; align-items: stretch; gap: 12px; }
  .hero--landing .hero__actions .btn { width: 100%; min-width: 0; }
  /* home footer socials live in the menu overlay on phones (moved by main.js),
     stacked one per line and pinned to the bottom of the overlay; the theme
     toggle keeps its spot right under the links. Footer keeps just the © line. */
  .nav__links .footer__contact {
    display: block;
    margin-top: auto;
    padding-top: 18px;
  }
  .nav__links .footer__contact a {
    display: block;
    padding: 12px var(--gutter);
    font-size: 15px;
    color: var(--muted);
    text-decoration: none;
  }
}

.footer--home { flex-shrink: 0; }
.footer__contact { display: flex; gap: 24px; flex-wrap: wrap; }
.footer__contact a { color: var(--muted); text-decoration: none; transition: color .2s; }
.footer__contact a:hover { color: var(--fg); }

/* ----------------------------------------------------------
   REVEAL animation
   ---------------------------------------------------------- */
.reveal {
  opacity: 0;
  transform: translateY(16px);
  transition: opacity .7s ease, transform .7s ease;
}
.reveal.is-visible { opacity: 1; transform: none; }
@media (prefers-reduced-motion: reduce) {
  .reveal { opacity: 1; transform: none; transition: none; }
  html { scroll-behavior: auto; }
  /* unlock micro-motions + the skip link slide: appear/disappear instantly */
  .unlock__note, #f-token, #tokenForm .btn, .disclose, .skiplink { transition: none; }
}

/* ----------------------------------------------------------
   RESPONSIVE
   ---------------------------------------------------------- */
@media (max-width: 720px) {
  .projects { grid-template-columns: 1fr; }
  .about__grid { grid-template-columns: 1fr; gap: 36px; }
  .section__head { flex-direction: column; gap: 6px; }
}

/* ===========================================================
   INNER PAGES — work index, case studies, about
   =========================================================== */

/* page header for inner pages (smaller than the home hero) */
.pagehead { padding-top: 72px; padding-bottom: 36px; }
.section--worklist { padding-top: 24px; }
/* small screens: the back link sits in the header row (not in the pagehead),
   so the title doesn't need the desktop's tall head padding; on the work page
   the lede also sits closer to the case list */
@media (max-width: 860px) {
  .pagehead { padding-top: 30px; }
  .pagehead--work { padding-bottom: 22px; }
  .section--worklist { padding-top: 0; padding-bottom: 36px; }
}
.pagehead__eyebrow {
  font-size: 13px;
  letter-spacing: .14em;
  text-transform: uppercase;
  color: var(--muted);
  margin-bottom: 18px;
}
.pagehead__title {
  font-family: var(--serif);
  font-weight: 400;
  font-size: clamp(30px, 5vw, 52px);
  line-height: 1.08;
  letter-spacing: -0.02em;
  max-width: 20ch;
}
.pagehead__lede {
  margin-top: 22px;
  font-size: clamp(16px, 2.2vw, 19px);
  color: var(--muted);
  max-width: 56ch;
}
/* The inner-page intros (About, Selected cases, Say hi, the private page,
   dev-login) read as a body paragraph: three-column width (breathing before the
   75% line) and 16px, not the larger lede. */
.pagehead--about .pagehead__lede,
.pagehead--work .pagehead__lede,
.pagehead--sayhi .pagehead__lede,
.pagehead--unlock .pagehead__lede,
.pagehead--dev .pagehead__lede {
  max-width: calc(var(--content-width) * 0.75 - var(--gutter) - 2.5rem);
  font-size: 16px;
}

/* breadcrumb / back link */
.backlink {
  display: inline-block;
  margin-bottom: 26px;
  font-size: 14px;
  color: var(--muted);
  text-decoration: none;
}
.backlink:hover { color: var(--fg); }
/* redundant on desktop / landscape tablets (the nav bar is right there); shown
   only ≤860px, where the nav is behind the hamburger and main.js moves it into
   the header row — mirrors .case__back's rule above */
@media (min-width: 861px) {
  .backlink { display: none; }
}

/* ----------------------------------------------------------
   CASE STUDY layout — sticky sidebar + body
   ---------------------------------------------------------- */
.case {
  max-width: var(--content-width);
  margin-inline: auto;
  padding-inline: var(--gutter);
  padding-block: 28px 96px;
  display: grid;
  grid-template-columns: 240px 1fr;
  gap: 56px;
  align-items: start;
}
.case__nav {
  position: sticky;
  top: 104px;
  font-size: 14px;
}
.case__back {
  display: inline-block;
  margin-bottom: 26px;
  color: var(--muted);
  text-decoration: none;
}
/* on desktop / landscape tablets the sidebar already lists all cases, so the
   back link is redundant there; it only serves small screens, where main.js
   moves it into the header row */
@media (min-width: 861px) {
  .case__nav .case__back { display: none; }
}
.case__back:hover { color: var(--fg); }
.case__navlabel {
  font-size: 12px;
  letter-spacing: .12em;
  text-transform: uppercase;
  color: var(--muted);
  margin-bottom: 14px;
}
.case__list { list-style: none; margin-bottom: 26px; }
.case__list li { margin: 0; }
.case__list a {
  display: block;
  padding: 9px 0;
  color: var(--muted);
  text-decoration: none;
  border-bottom: 1px dashed var(--line);
  transition: color .2s;
}
.case__list a:hover { color: var(--fg); }
.case__list a.current { color: var(--fg); font-weight: 500; }
.case__list a.current::before { content: "→ "; content: "→ " / ""; color: var(--accent-text); }
.case__aboutlink {
  font-size: 14px;
  color: var(--fg);
  text-decoration: none;
}
.case__aboutlink:hover { text-decoration: underline; }

/* case body / prose */
.case__body { max-width: 68ch; }
/* small serif kicker above the case title: emoji + sector ("🛒 E-commerce") */
.case__eyebrow {
  font-family: var(--serif);
  font-weight: 500;
  font-size: clamp(17px, 2.2vw, 21px);
  margin-bottom: 6px;
}
.case__title {
  font-family: var(--serif);
  font-weight: 500;
  font-size: clamp(26px, 3.8vw, 38px);
  line-height: 1.15;
  letter-spacing: -0.02em;
}
.case__tagline {
  margin-top: 18px;
  font-size: clamp(17px, 2.4vw, 21px);
  font-style: italic;
  color: var(--muted);
}
/* plain variant used with the eyebrow+title header: reads exactly like a
   normal body paragraph (16px, fg color, no italic) */
.case__tagline--plain {
  font-style: normal;
  font-size: 16px;
  color: var(--fg);
  margin-top: 14px;
}
/* MA-style subtitle: continues the dissertation title, so it reads larger than
   body text (like the base tagline) but regular and in the fg color */
.case__body .case__tagline--subtitle {
  font-style: normal;
  color: var(--fg);
  font-size: clamp(17px, 2.4vw, 21px);
  margin-top: 7px;
}
/* small muted context line under the header (e.g. the MA Project degree line);
   scoped under .case__body to out-rank the `.case__body p` fg color */
.case__body .case__meta {
  margin-top: 16px;
  font-size: 15px;
  color: var(--muted);
}

/* end-of-case direction back up to the list — deliberately lighter than a real
   CTA: dashed pill, muted text, back-arrow (hierarchy). Only shown on small
   screens (≤860px), where the sidebar's case navigation is hidden. */
.case__next {
  display: none;
  align-items: center;
  justify-content: center;
  gap: 10px;
  margin-top: 64px;
  padding: 17px 24px;
  border: 1px dashed var(--line-strong);
  border-radius: 999px;
  font-size: 16px;
  color: var(--muted);
  text-decoration: none;
  transition: color .2s, border-color .2s;
}
.case__next:hover { color: var(--fg); border-color: var(--accent); }
.case__next-arrow { transition: transform .25s; }
.case__next:hover .case__next-arrow { transform: translateX(-4px); }
@media (max-width: 860px) { .case__next { display: flex; } }
.case__body h2 {
  font-family: var(--serif);
  font-weight: 500;
  font-size: clamp(21px, 3vw, 28px);
  letter-spacing: -0.01em;
  margin-top: 52px;
  margin-bottom: 4px;
}
.case__body h3 {
  font-size: 16px;
  font-weight: 600;
  margin-top: 30px;
  color: var(--fg);
}
.case__body p { margin-top: 16px; color: var(--fg); }
.case__body p.lead { color: var(--muted); }
/* sub-point (the ">"-prefixed notes in the original) */
.case__body .point {
  margin-top: 22px;
  padding-left: 18px;
  border-left: 2px solid var(--accent);
}
.case__body .point > strong { display: block; margin-bottom: 4px; }

/* a point with a small image beside it (text left, image right) */
.point-row {
  display: flex;
  gap: 36px;
  align-items: flex-start;
  margin-top: 22px;
}
.point-row > .point,
.point-row > p,
.point-row > div { flex: 1 1 auto; margin-top: 0; min-width: 0; }
/* a text column may hold a heading + paragraph; don't let the heading's top
   margin push it below the figure it sits beside */
.point-row > div > :first-child { margin-top: 0; }
.point-row .fig { flex: 0 0 34%; max-width: 34% !important; margin: 2px 0 0; }
@media (max-width: 760px) {
  .point-row { flex-direction: column; gap: 0; }
  .point-row .fig { flex: none; max-width: 60% !important; }
}

/* figure / image */
.fig { margin: 34px 0; }
/* click-to-pause GIFs (main.js adds the hook wherever an img carries
   data-still): the chip is the visible state; the img itself is the button */
.fig--pausable { position: relative; }
.fig--pausable img { cursor: pointer; }
.figpause {
  position: absolute;
  right: 10px;
  bottom: 10px;
  font-family: var(--mono);
  font-size: 11px;
  background: var(--fg);   /* ink pill on light, cream pill on dark — the
                              skiplink / primary-button family */
  color: var(--bg);
  opacity: 0.85;
  border-radius: 999px;
  padding: 4px 11px;
  pointer-events: none;    /* clicks fall through to the image button */
}
.fig__img,
.fig__video {
  display: block;
  width: 100%;
  height: auto;
  border-radius: 12px;
  border: 1px solid var(--line);
}
.fig__video { background: #000; }
/* opt a figure out of small screens (e.g. the tall portrait phone video that eats
   vertical space on mobile) */
@media (max-width: 860px) { .fig--hide-mobile { display: none; } }
/* phones: on .case--fluid-imgs pages the case images go full width, aligned
   with the text column, overriding their inline desktop sizing. Figures inside
   .img-pair are deliberately NOT covered (owner-chosen exceptions, e.g. the
   insurance detail pair). */
@media (max-width: 640px) {
  .case--fluid-imgs .case__body > .fig { max-width: 100% !important; margin-inline: 0; }
  /* the e-commerce method images (the only point-row figures) stay smaller
     and centred on phones, like the .img-pair exceptions */
  .case--fluid-imgs .case__body .point-row .fig { max-width: 80% !important; margin-inline: auto; }
}
.fig__ph {
  aspect-ratio: 16 / 9;
  border: 1px dashed var(--line-strong);
  border-radius: 12px;
  background:
    repeating-linear-gradient(-45deg, transparent 0 11px, color-mix(in srgb, var(--fg) 3%, transparent) 11px 12px);
  display: grid;
  place-items: center;
  color: var(--muted);
  font-size: 12px;
  letter-spacing: .1em;
  text-transform: uppercase;
}
.fig__ph::after { content: "image"; opacity: .5; }
.fig figcaption {
  margin-top: 12px;
  font-size: 13px;
  color: var(--muted);
  text-align: center;
}
/* two small images side by side (left-aligned, detail-level) */
.img-pair { display: flex; gap: 28px; align-items: flex-start; margin: 34px 0; }
.img-pair .fig { flex: 0 0 40%; margin: 0; }
@media (max-width: 760px) {
  .img-pair { flex-direction: column; }
  .img-pair .fig { flex: none; max-width: 60%; }
}
/* phones: the stacked pair images sit centred in the column (this must come
   after the base .img-pair rule above, whose flex-start would otherwise win) */
@media (max-width: 640px) {
  .img-pair { align-items: center; }
}

/* confidentiality note */
.confidential {
  margin-top: 64px;
  padding-top: 22px;
  border-top: 1px dashed var(--line-strong);
  font-size: 13px;
  color: var(--muted);
  max-width: 60ch;
}

/* ----------------------------------------------------------
   ABOUT page — CV / experience list
   ---------------------------------------------------------- */
.cv { margin-top: 8px; }
.cv__group { margin-top: 48px; }
.cv__grouptitle {
  font-family: var(--serif);
  font-weight: 500;
  font-size: clamp(22px, 3vw, 30px);
  margin-bottom: 18px;
}
.cv__item {
  padding: 18px 0;
  border-bottom: 1px dashed var(--line);
}
.cv__role { font-weight: 600; }
.cv__meta { color: var(--muted); font-size: 14px; margin-top: 2px; }
/* "(IxDF)" is redundant beside the full "Interaction Design Foundation" name;
   drop it on phones so the certification line stays on one row */
@media (max-width: 640px) { .cv__abbr { display: none; } }
/* verification / institution links inside the CV list */
.cv__item a {
  color: inherit;
  text-decoration: underline;
  text-decoration-color: var(--line-strong);
  text-underline-offset: 2px;
  transition: color .2s, text-decoration-color .2s;
}
.cv__item a:hover {
  color: var(--accent-text);
  text-decoration-color: var(--accent-text);
}
.cv__desc { color: var(--muted); font-size: 14px; margin-top: 8px; max-width: 70ch; }
/* One flowing inline paragraph (NOT flex): the whole list wraps at spaces, so a
   long item breaks at its own blanks and the next item continues right where it
   ends, filling each line. */
.cv__tags { margin-top: 6px; max-width: 70ch; line-height: 1.85; }
/* each item stays whole (breaks happen BETWEEN items, at the source whitespace);
   the one genuinely-long entry opts back into internal wrapping via .cv__wrap */
.cv__tags span { font-size: 14px; color: var(--fg); white-space: nowrap; }
.cv__tags span.cv__wrap { white-space: normal; }
/* A middot trails each item (except the last), so it stays glued to the item it
   follows: when a pair breaks across lines the dot ends the first line with its
   item ("… proficiency ·") and the next item starts the following line clean. */
.cv__tags span:not(:last-child)::after { content: "·"; color: var(--muted); margin-left: 6px; }

/* mode toggle for "in words" / "as a list" */
/* sticky wrapper: keeps the pills usable as navigation while scrolling
   through both "in words" and "as a list" sections (handy on mobile) */
.modes-bar {
  position: sticky;
  top: 10px;
  z-index: 10;
  max-width: var(--content-width);
  margin: 14px auto 0;
  padding-inline: var(--gutter);
  transition: opacity .3s ease, visibility .3s ease;
}
/* About without a valid key (server sets body.no-key): drop the words/list
   switch, the whole "in words" section, and the "as a list" title — the full
   experience is a key-holder perk. */
body.no-key .modes-bar,
body.no-key #words { display: none; }
body.no-key #list .section__title { display: none; }
/* set by main.js when the closing contact section scrolls into view:
   the pills bow out at the end of the page instead of hovering forever */
.modes-bar--away { opacity: 0; visibility: hidden; }
.modes {
  display: inline-flex;
  gap: 4px;
  padding: 4px;
  border: 1px dashed var(--line-strong);
  border-radius: 999px;
  background: var(--bg);
}
.modes a {
  padding: 7px 16px;
  border-radius: 999px;
  font-size: 14px;
  text-decoration: none;
  color: var(--muted);
}
.modes a.active { background: var(--fg); color: var(--bg); }

/* ----------------------------------------------------------
   SAY HI — contact form
   ---------------------------------------------------------- */
/* align the form column to the grid, same as the page heading */
.section--form {
  max-width: var(--content-width);
  margin-inline: auto;
  padding-inline: var(--gutter);
}
.wave { display: inline-block; }

/* the drawn "eyes closed" mark closing the unlock title (it replaced a 🔒).
   Inline SVG, not an <img>, so its paths inherit the title's ink (currentColor)
   and follow the theme; it scales with the title and sits on the same baseline. */
/* The artwork carries empty padding inside its own canvas (~8% on the left, and
   more above the ink than below), so a plain baseline + margin leaves it drifting
   high and far from the word. Both numbers below cancel that padding out: the
   negative margin pulls the ink up to a normal word gap, and the offset drops the
   ink's centre onto the title's cap-height centre. */
.pagehead__eyes {
  display: inline-block;
  height: 1.2em;      /* stands a little taller than the capitals, so it reads as a mark, not a letter */
  width: auto;
  vertical-align: -0.243em;
  margin-left: -0.3em;
}
.form {
  max-width: 560px;
  display: grid;
  gap: 22px;
}
.form__row { display: grid; gap: 8px; }
.form__row label {
  font-size: 12px;
  letter-spacing: .1em;
  text-transform: uppercase;
  color: var(--muted);
}
.form input,
.form textarea {
  font-family: var(--sans);
  font-size: 16px;
  color: var(--fg);
  background: transparent;
  border: 1px solid var(--input-line);
  border-radius: 10px;
  padding: 12px 14px;
  transition: border-color .2s;
}
.form input:focus,
.form textarea:focus {
  border-color: var(--accent);
}
.form textarea { resize: vertical; min-height: 130px; }
.form .btn { justify-self: start; margin-top: 4px; }
.form__status {
  max-width: 560px;
  font-size: 18px;
  color: var(--fg);
  font-weight: 600;
}
/* the thank-you ✳ lingers a beat, then rolls off to the right and is gone.
   The animation only starts once [hidden] lifts off the parent, so it plays
   exactly when the thank-you appears. */
.form__status-roll {
  display: inline-block;
  animation: status-roll-away 3.4s 1.2s forwards;
}
/* cartoon exit: creeps right, backs up to wind up, holds a beat, then bolts */
@keyframes status-roll-away {
  0%   { transform: translateX(0) rotate(0); opacity: 1; animation-timing-function: ease-in-out; }
  45%  { transform: translateX(30px) rotate(90deg); animation-timing-function: ease-in-out; }
  66%  { transform: translateX(0) rotate(-45deg); }
  88%  { transform: translateX(0) rotate(-45deg); animation-timing-function: cubic-bezier(.75, 0, 1, 1); }
  99%  { opacity: 1; }
  100% { transform: translateX(90vw) rotate(3turn); opacity: 0; }
}
@media (prefers-reduced-motion: reduce) { .form__status-roll { animation: none; } }
.form__alt {
  margin-top: 30px;
  font-size: 14px;
  color: var(--muted);
}
/* in-sentence links stay underlined: color alone (1.02:1 vs the muted text
   around them) is invisible to color-blind visitors (Lighthouse catch) */
.form__alt a { color: var(--accent-text); text-decoration: underline; text-underline-offset: 2px; }

/* Unlock page: intro line above the request form + inline error notes */
.form__legend {
  font-size: 15px;
  color: var(--muted);
}
.form__note { font-size: 14px; }
.form__note--error { color: var(--error); }
.form__req { color: var(--accent-text); }

/* rejected code sits in the field in red; inline status line under the code */
.form input.is-invalid { color: var(--error); }
/* inline status: a soft wash box hanging just under the code field. Text stays
   body-colour on the indigo wash (indigo text was too low-contrast in dark);
   the rejected-code message keeps the red, on a red wash. */
/* the token form uses margins, not a grid gap, so the status note can collapse
   without the row's fixed gap snapping the layout up when it hides */
#tokenForm { gap: 0; }
#tokenForm .btn { margin-top: 26px; }
.unlock__note {
  max-width: 560px;
  padding: 10px 14px;
  border-radius: 0 0 8px 8px;   /* square top (meets the field), rounded bottom */
  font-size: 14px;
  font-weight: 600;
  line-height: 1.5;
  overflow: hidden;
  transition: opacity .24s ease, max-height .28s ease-out, padding .28s ease-out;
}
/* collapsing back to default: fade + retract the height (JS pins max-height first
   so it can animate to 0 instead of snapping) */
.unlock__note.is-fading { opacity: 0; max-height: 0 !important; padding-top: 0; padding-bottom: 0; }
.unlock__note--error { color: var(--error); background: var(--error-wash); }
.unlock__note--info { color: var(--fg); background: var(--accent-wash); }
/* ease the field's colour + corners back as the message clears */
#f-token { transition: border-color .2s ease, color .3s ease, border-radius .3s ease; }
/* when a message hangs off the field, square the field's bottom so the two read
   as one shape (JS adds .is-fused in the message states and removes it on edit,
   so the corners round together with the collapse instead of after it). */
.form__row.is-fused #f-token {
  border-bottom-left-radius: 0;
  border-bottom-right-radius: 0;
}
/* the renewal fold fades before it collapses on an edit */
.disclose { transition: opacity .3s ease; }
.disclose.is-fading { opacity: 0; }

/* The unlock page stacks two forms; give the second one breathing room */
.form + .form { margin-top: 42px; }

/* Unlock page: the request form folds behind a disclosure line, so the code
   entry stays the primary path. Native <details> — works with no JS and the
   state script can force it open. */
.disclose { max-width: 560px; }
/* NB block, not inline-flex: as a flex container the chevron became a sibling of
   the whole text block, so once the sentence wrapped (it does on every phone) it
   drifted to the far right and floated between the two lines. As normal inline
   content it simply follows the last word, wrapped or not. */
.disclose__summary {
  list-style: none;              /* strip the default triangle marker */
  cursor: pointer;
  display: block;
  font-size: 15px;
  color: var(--muted);
  transition: color .2s;
}
.disclose__summary::-webkit-details-marker { display: none; }
.disclose__summary:hover { color: var(--fg); }
.disclose__summary::after {         /* a small chevron that flips when open */
  content: "";
  display: inline-block;
  width: 7px; height: 7px;
  margin-left: 10px;
  border-right: 1.5px solid currentColor;
  border-bottom: 1.5px solid currentColor;
  transform: translateY(-2px) rotate(45deg);
  transition: transform .2s;
}
.disclose[open] .disclose__summary { color: var(--fg); }
.disclose[open] .disclose__summary::after { transform: translateY(1px) rotate(-135deg); }
/* during the expired revert the fold is still technically open, but point the
   chevron down so it settles with the rest of the motion (not after) */
.disclose.is-reverting .disclose__summary::after { transform: translateY(-2px) rotate(45deg); }
.disclose .form { margin-top: 24px; }
/* renewal (expired state): the fold is forced open, so drop its toggle */
.disclose--forced .disclose__summary { display: none; }
.disclose--forced .form { margin-top: 0; }

/* private page: pin the footer to the foot even when the form is short, so the
   form's closing rule sits by the form (not crammed onto the footer's divider) */
body.unlock { display: flex; flex-direction: column; min-height: 100vh; min-height: 100dvh; }
/* flex items with auto side-margins shrink to content; force full width so the
   nav/footer keep spanning the content column (same fix as body.home) */
body.unlock > .nav,
body.unlock > main,
body.unlock > .footer { width: 100%; }
body.unlock main { flex: 1 0 auto; }

/* the closing rule is the bottom edge of the accordion itself, only as wide as
   its content — so it sits under "No link?…" when closed and slides down (widening
   to the form) as the fold opens. Not a full-bleed line. */
.section--form--ruled { padding-bottom: 28px; }
.section--form--ruled .disclose {
  width: fit-content;
  max-width: 100%;
  margin-top: 40px;   /* gap below the code form (or the status note, when shown) */
  padding-bottom: 7px;
  border-bottom: 1px dashed var(--line-strong);
}
.section--form--ruled .disclose .form { width: 560px; max-width: 100%; }

/* /k/ loading page: instant static paint while the backend wakes up.
   Mono line + blinking caret, the hello-world motif. */
.kload {
  min-height: 72vh;
  display: grid;
  place-items: center;
  padding-inline: var(--gutter);
  text-align: center;
}
.kload__line {
  font-family: var(--mono);
  font-size: 15px;
  color: var(--muted);
}
.kload__caret {
  display: inline-block;
  width: 9px;
  height: 1.05em;
  margin-left: 7px;
  vertical-align: text-bottom;
  background: var(--accent);
  animation: kload-blink 1s steps(1) infinite;
}
@keyframes kload-blink { 50% { opacity: 0; } }
.kload__err { font-size: 15px; color: var(--fg); max-width: 460px; }
.kload__err a { color: var(--accent-text); }

@media (max-width: 860px) {
  .case { grid-template-columns: 1fr; gap: 0; padding-bottom: 36px; }
  .case__nav { position: static; top: auto; }
  /* the back link moves into the header (main.js); the rest of the sidebar
     is redundant next to the burger menu, so the case body leads the page */
  .case__list,
  .case__navlabel,
  .case__aboutlink { display: none; }
}

/* ===========================================================
   PLAYGROUND (play.html) — vibe-designed, vibe-coded side projects
   =========================================================== */
/* the lede spans ~three grid columns (same calc as the About page's narrative,
   breathing just before the 75% line) and is typed like the project stories
   below it, not like the bigger ledes on the other pages */
body.play .pagehead__lede {
  max-width: calc(var(--content-width) * 0.75 - var(--gutter) - 2.5rem);
  font-size: 15px;
}

/* the Playground title in mono, matching the "Playground" word in the work-list
   teaser (and echoing the home page's mono "hello world"); other pages' headers
   stay serif */
body.play .pagehead__title {
  font-family: var(--mono);
  font-weight: 500;
  letter-spacing: -0.03em;
}

/* mono accent line under the page title */
.pagehead__vibe {
  margin-top: 14px;
  font-family: var(--mono);
  font-weight: 500;
  font-size: clamp(13px, 1.8vw, 15px);
  letter-spacing: -0.01em;
  color: var(--accent-text);
}

/* "How this works" — four principles, one per grid column (like the nav links) */
.how { padding-inline: 0; margin-top: 26px; }
.how__label {
  padding-inline: var(--gutter);
  font-size: 13px;
  font-weight: 500;
  letter-spacing: .14em;
  text-transform: uppercase;
  color: var(--muted);
  margin-bottom: 20px;
}
.how__grid { display: grid; grid-template-columns: repeat(4, 1fr); row-gap: 26px; }
.how__cell { padding-inline: var(--gutter); }
.how__key {
  display: block;
  font-family: var(--mono);
  font-size: 13px;
  font-weight: 500;
  color: var(--accent-text);
  margin-bottom: 6px;
}
.how__cell p { font-size: 14px; color: var(--muted); max-width: 30ch; }
/* small screens: the cells are wide enough to be their own measure, so the
   30ch cap only leaves a ragged gap — let the text run the full cell */
@media (max-width: 860px) { .how__cell p { max-width: none; } }
/* small screens: two cells per row (they still land on the guide lines);
   phones: one per row (site rule — portrait tablets follow mobile, but the
   four principles still read two-up at 768) */
@media (max-width: 860px) { .how { margin-top: 18px; } .how__grid { grid-template-columns: repeat(2, 1fr); } }
@media (max-width: 640px) { .how__grid { grid-template-columns: 1fr; row-gap: 22px; } }

/* project entries — text left, sketch right, dashed hairlines between */
.entries { padding-top: 44px; }
/* the CTA is a grid cell of its own (not a child of the text block), so small
   screens can put it BELOW the product capture — text, then the visual, then
   the invitation to visit. On desktop the areas rebuild the original layout:
   text over CTA on the left, the capture spanning both rows on the right. */
.entry {
  display: grid;
  grid-template-columns: 1.35fr 1fr;
  grid-template-areas:
    "text media"
    "cta  media";
  column-gap: 52px;
  row-gap: 0;
  align-items: center;
  padding-block: 56px;
  border-top: 1px dashed var(--line-strong);
}
.entry__text { grid-area: text; }
.entry__media { grid-area: media; }
.entry__cta { grid-area: cta; }
.entry:last-of-type { border-bottom: 1px dashed var(--line-strong); }
.entry__eyebrow {
  font-family: var(--serif);
  font-weight: 500;
  font-size: clamp(16px, 2vw, 19px);
  margin-bottom: 6px;
}
.entry__title {
  font-family: var(--serif);
  font-weight: 500;
  font-size: clamp(24px, 3.4vw, 34px);
  line-height: 1.15;
  letter-spacing: -0.02em;
}
.entry__tagline { margin-top: 12px; font-size: 16px; }
.entry__story { margin-top: 16px; font-size: 15px; color: var(--muted); max-width: 58ch; }
.entry__facts { margin-top: 20px; font-family: var(--mono); font-size: 12.5px; color: var(--muted); }
.entry__cta { margin-top: 26px; display: flex; align-items: center; gap: 16px; flex-wrap: wrap; }
.entry__cta .ext { font-size: 13px; color: var(--muted); }
/* the portfolio sign-off shares the Visit buttons' text (Inter 15/500) but drops
   the pill frame and padding: it is a statement, not a link, so no border, no
   pointer, no hover change; it sits flush with the story's left edge */
.entry__here { cursor: default; border: 0; padding: 0; }
.entry__here:hover { color: var(--fg); font-style: normal; }
/* small screens: the entry stacks — story, then the capture running the full
   column width (a 420px cap left it stranded next to full-width text on
   portrait tablets), then the visit link under the visual it belongs to */
@media (max-width: 860px) {
  .entry {
    grid-template-columns: 1fr;
    grid-template-areas: "text" "media" "cta";
    row-gap: 30px;
    padding-block: 44px;
  }
  .entry__media { max-width: none; }
  .entry__cta { margin-top: 0; }
}
/* phones: tighter rhythm */
@media (max-width: 640px) {
  .entries { padding-top: 30px; }
  .entry { row-gap: 26px; padding-block: 36px; }
}

/* figures holding a real capture of each live product */
.sketch { margin: 0; }
.sketch figcaption { margin-top: 12px; font-size: 13px; color: var(--muted); text-align: center; }
/* a real capture of a live product (screenshot or looping GIF), framed like the
   case-page figures */
.entry__shot {
  display: block;
  width: 100%;
  height: auto;
  border-radius: 12px;
  border: 1px solid var(--line);
}
/* the home-page capture is clickable: its caret becomes a period, like the real one */
/* end-of-page direction to the case studies — same pill as the case pages'
   "Read another case", but always visible here (this page has no sidebar) */
.nextwrap { padding-block: 24px 96px; }
.case__next--visible { display: flex; margin-top: 0; }
@media (max-width: 860px) { .nextwrap { padding-block: 20px 64px; } }
@media (max-width: 640px) { .nextwrap { padding-block: 16px 48px; } }

/* the accent ✳ leading the third entry's eyebrow, where the site names itself
   (same glyph and color as the nav's current-page marker) */
.play-mark { color: var(--accent); }

/* work list: the Playground entry standing as the last case-study list row.
   It inherits the case-row rhythm (.projects--list .project) — full width, dashed
   hairline, same padding — but is typed in mono and carries the bouncing-ball
   animation (built and triggered in main.js). Contiguous with the list above:
   margin-top:0 and border-top:0, so there's no double hairline under it. */
.projects--play { margin-top: 0; border-top: 0; }
.projects--play .project { position: relative; }
/* the case rows turn their title blue on hover; the Playground row keeps its
   title in the normal ink (its accent is the bouncing ball, not a colour shift) */
.projects--play .project:hover h2 { color: var(--fg); }
/* "Playground" reads at case-title size but in mono (the playground voice); the
   👾 leads it, and the vibe line sits below like a case-row subtitle. */
.projects--play .project__meta h2 {
  display: flex;
  align-items: baseline;
  gap: 12px;
  font-family: var(--mono);
  font-weight: 500;
  letter-spacing: -0.02em;
}
.projects--play .pg-emoji { display: inline-block; will-change: transform; }
.projects--play .project__meta .play-vibe {
  font-family: var(--mono);
  font-size: 14px;
  color: var(--muted);
  margin-top: 4px;
}

/* the bouncing ball — a black dot that becomes the word's full stop. main.js
   positions .pd-x over "Playground" and builds the keyframes; it starts once,
   when the row scrolls into view, and plays through to rest. .pd-x = horizontal
   travel, .pd-y = the bounce (hidden until triggered), .pd-b = squash & stretch. */
.projects--play .pd-x {
  position: absolute;
  left: 0; top: 0;
  pointer-events: none;
  will-change: transform;
}
.projects--play .pd-y {
  display: block;
  width: 5px; height: 5px;
  margin-left: -2.5px;
  opacity: 0;                /* hidden until the animation is triggered */
  will-change: transform;
}
.projects--play .pd-b {
  display: block;
  width: 100%; height: 100%;
  border-radius: 50%;
  background: var(--fg);
  transform-origin: 50% 100%;
  will-change: transform;
}
@media (prefers-reduced-motion: reduce) {
  .projects--play .pd-x { display: none; }   /* no bouncing ball for reduced motion */
}
