/* ==========================================================================
   Portfolio — base styles
   Radically minimal, Apple-inspired: generous whitespace, restrained type,
   quiet motion, near-monochrome palette. Single centered column, in the
   spirit of a digital business card: mark, name, then Who / What / Where.
   ========================================================================== */

:root {
  --bg: #fbfbfd;
  --bg-elevated: #ffffff;
  --text: #1d1d1f;
  --text-secondary: #6e6e73;
  --border: rgba(0, 0, 0, 0.08);
  --accent: #1d1d1f;
  /* Links are colour-only — no underline. Colour is the entire
     affordance, so it's a real indigo rather than a tint of one. */
  --link: #4f46e5;
  --link-hover: #3f38b8;
  /* Field chrome — deliberately monochrome. A blue focus ring is the
     single most "system default" thing on a page like this; a slightly
     darker version of the field's own border reads as intentional. */
  --field-border: rgba(0, 0, 0, 0.11);
  --field-border-focus: rgba(0, 0, 0, 0.26);
  --shadow: 0 1px 2px rgba(0, 0, 0, 0.04), 0 12px 32px rgba(0, 0, 0, 0.06);
  --radius-lg: 20px;
  --radius-md: 14px;
  --radius-sm: 8px;
  --ease: cubic-bezier(0.22, 1, 0.36, 1);
  --memoji-size: clamp(92px, 13vw, 136px);
  --memoji-radius: 26%;
  /* The source clips carry a 1-2px darker border baked into their
     outermost rows (~#efeff2 against their own ~#fafafc field), which
     rendered as a hairline seam across the top and bottom of the memoji
     box — an edge where there should have been none, since the field
     and the page are the same colour to within a level. Nothing in CSS
     can repaint those pixels, so they are cropped: every layer in the
     box is blown up just past its container and the rounded overflow
     clips the bad rows away. 2.5% of a 480px source is 6px off each
     edge, comfortably clear of the gradient without eating any face. */
  --memoji-bleed: 1.025;
  /* Crossfade length between memoji clips. Keep in sync with FADE_MS in
     app.js. Short on purpose: the clips share a near-identical neutral
     pose, so a long dissolve just reads as a soft double-exposure and
     swallows the opening beat of an expression. */
  --memoji-fade: 220ms;
  --col-width: 620px;
  --col-width-wide: 960px;
}

* {
  box-sizing: border-box;
}

html {
  scroll-behavior: smooth;
}

body {
  margin: 0;
  background: var(--bg);
  color: var(--text);
  font-family: -apple-system, BlinkMacSystemFont, "SF Pro Text", "SF Pro Display",
    "Helvetica Neue", Helvetica, Arial, sans-serif;
  -webkit-font-smoothing: antialiased;
  text-rendering: optimizeLegibility;
  line-height: 1.5;
}

/* While the gate is up the document is pinned. `position: fixed` on the
   body (rather than just overflow: hidden) is the part that matters on
   iOS: overflow alone doesn't stop Safari from scrolling the page to
   bring a focused input into view, which is what used to shove the
   memoji off the top of the screen the moment the keyboard opened.
   The gate only ever appears at scroll position 0, so nothing needs to
   be saved and restored. */
/* NOTE: the gate state lives on <html>, not <body>, and is set by an
   inline script in <head> before the first paint. Putting it on <body>
   in the markup meant every load — including an already-unlocked one —
   rendered one frame in the locked layout and then relaid out when the
   script ran, which is what read as the page stuttering/shifting on
   refresh. */
html.is-locked {
  height: 100%;
  overflow: hidden;
}

html.is-locked body {
  position: fixed;
  top: 0;
  left: 0;
  width: 100%;
  height: 100%;
  overflow: hidden;
  overscroll-behavior: none;
}

a {
  color: inherit;
  text-decoration: none;
}

img {
  max-width: 100%;
  display: block;
}

button, input {
  font-family: inherit;
}

::selection {
  background: rgba(29, 29, 31, 0.12);
}

.visually-hidden {
  position: absolute;
  width: 1px;
  height: 1px;
  overflow: hidden;
  clip: rect(0 0 0 0);
  white-space: nowrap;
}

/* ---------- Mast (memoji + name — replaces the old header/nav) ---------- */

/* Sits in normal document flow, not fixed — it simply is the top of the
   page, like the mark-then-name masthead it's modeled on. Its own opaque
   background plus a higher stacking order than the lock overlay is what
   keeps it crisp and un-blurred while the gate is up (see .lock-overlay). */
.mast {
  position: relative;
  z-index: 70;
  background: var(--bg);
  text-align: center;
  /* Sits close to the top, as in the reference layout: the memoji IS the
     first thing, so a deep band of nothing above it just pushes the name
     and the first section down out of the opening view. */
  padding: 40px 24px 6px;
  /* While the gate is up the mast is nudged to follow the VISUAL
     viewport. On iOS, focusing the field slides the visible band of the
     screen down to make room for the keyboard; without this the memoji,
     the name and the field all end up above that band — which reads as
     the page having scrolled away from you. transform doesn't affect
     layout, so nothing reflows when the gate is dismissed. */
  transition: transform 0.2s var(--ease);
}

html.is-locked .mast {
  transform: translateY(var(--vv-top, 0px));
}

.memoji {
  position: relative;
  width: var(--memoji-size);
  height: var(--memoji-size);
  margin: 0 auto 6px;
  border-radius: var(--memoji-radius);
  overflow: hidden;
  background: var(--bg);
}

/* The still first frame, underneath every clip. It costs nothing while
   the video is playing (it's completely covered by an opaque clip) and
   it is the entire difference between a graceful "paused face" and a
   blank rounded box when the browser won't or can't play video —
   Low Power Mode on iOS, a backgrounded tab whose decoder was reclaimed,
   a restore from the back/forward cache. */
.memoji-still {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  object-fit: cover;
  object-position: center center;
  transform: scale(var(--memoji-bleed));
  z-index: 0;
}

/* All three clips are stacked in the same box. The trick to an invisible
   transition is that the OUTGOING clip stays fully opaque underneath for
   the whole crossfade while the INCOMING clip fades in on top of it. The
   two clips are opaque rectangles, so at no point does the page
   background show through — which is what used to make the change read
   as a dip or a flash rather than a dissolve. */
.memoji-clip {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  object-fit: cover;
  object-position: center center;
  opacity: 0;
  z-index: 1;
  /* Promote each clip to its own compositor layer so the crossfade is a
     pure GPU opacity blend — no repaint, no dropped frames mid-fade. */
  transform: translateZ(0) scale(var(--memoji-bleed));
  backface-visibility: hidden;
  transition: opacity var(--memoji-fade) linear;
}

/* The clip currently being faded IN sits above the one being faded out. */
.memoji-clip.is-top {
  z-index: 2;
}

.memoji-clip.is-active {
  opacity: 1;
}

/* A linear blend is the right curve here: with an eased curve the two
   clips are both near half-opacity for longer, which is exactly when a
   dissolve is most visible. Linear spends the least time in that zone. */

@media (prefers-reduced-motion: reduce) {
  :root {
    --memoji-fade: 120ms;
  }
}

.site-name {
  font-size: clamp(30px, 5vw, 44px);
  font-weight: 700;
  letter-spacing: -0.02em;
  margin: 0;
}

main {
  padding-top: 26px;
}

/* Vertical rhythm, matched to the reference layout: the lines inside a
   stanza are tight (gap: 2px) and the gap BETWEEN stanzas is about two
   body line-heights — enough that Who / What / Where read as separate
   blocks, not so much that they read as separate screens. The gap is
   twice this padding, so ~44px. */
.section {
  max-width: var(--col-width);
  margin: 0 auto;
  padding: 16px 28px;
}

.section-wide {
  max-width: var(--col-width-wide);
}

.reveal {
  opacity: 0;
  transform: translateY(24px);
  transition: opacity 0.8s var(--ease), transform 0.8s var(--ease);
}

.reveal.is-visible {
  opacity: 1;
  transform: translateY(0);
}

/* Applied for a single frame to sections that were already on screen at
   load, so they simply are visible rather than animating in. */
.reveal.is-instant {
  transition: none;
}

.section-heading {
  font-size: clamp(24px, 4vw, 30px);
  font-weight: 700;
  letter-spacing: -0.02em;
  margin: 0 0 8px;
}

.section-lede {
  font-size: 16px;
  color: var(--text-secondary);
  max-width: 60ch;
  margin: -4px 0 32px;
  line-height: 1.6;
}

/* Who / What / Where content — short, equal-weight lines */

.info-lines {
  display: flex;
  flex-direction: column;
  gap: 2px;
}

.info-lines p {
  margin: 0;
  font-size: 17px;
  color: var(--text);
  line-height: 1.45;
}

.info-link {
  display: flex;
  align-items: center;
  gap: 10px;
}

/* Emoji, not line icons. Given a fixed em-based box and centred so the
   labels beside them line up in a column regardless of how wide any one
   emoji renders on a given platform. */
.info-icon {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 1.5em;
  flex-shrink: 0;
  font-size: 16px;
  line-height: 1;
  /* Ask for the colour emoji font first so these never fall back to a
     monochrome glyph. */
  font-family: "Apple Color Emoji", "Segoe UI Emoji", "Noto Color Emoji",
    "Segoe UI Symbol", sans-serif;
}

/* A trailing emoji at the end of a Who line. Unlike .info-icon this gets
   no fixed box — it sits at the end of the sentence, so nothing needs to
   line up under it, and a fixed width would only open a gap after the
   narrow glyphs. A touch smaller than the 17px text so it reads as
   punctuation on the line rather than as a second subject. */
/* Holds a phrase together across a line break. */
.nowrap {
  white-space: nowrap;
}

.line-emoji {
  font-size: 15px;
  font-family: "Apple Color Emoji", "Segoe UI Emoji", "Noto Color Emoji",
    "Segoe UI Symbol", sans-serif;
}

/* ---------- "Loves" — one rotating emoji ---------- */

/* Fixed-size box so the line's length never changes as the emoji
   swaps — otherwise the word "Loves" would shuffle sideways every five
   seconds. The emoji crossfades inside it. */
.loves-line {
  display: flex;
  align-items: center;
  gap: 6px;
}

.loves-slot {
  position: relative;
  display: inline-block;
  width: 1.5em;
  height: 1.5em;
  vertical-align: middle;
  font-size: 17px;
  line-height: 1.5em;
  text-align: center;
  font-family: "Apple Color Emoji", "Segoe UI Emoji", "Noto Color Emoji",
    "Segoe UI Symbol", sans-serif;
}

.loves-face {
  position: absolute;
  inset: 0;
  display: flex;
  align-items: center;
  justify-content: center;
  opacity: 0;
  transform: translateY(3px);
  transition: opacity 0.45s var(--ease), transform 0.45s var(--ease);
}

.loves-face.is-in {
  opacity: 1;
  transform: translateY(0);
}

@media (prefers-reduced-motion: reduce) {
  .loves-face {
    transform: none;
    transition: opacity 0.2s linear;
  }

  .loves-face.is-in {
    transform: none;
  }
}

/* Colour is the whole affordance — no underline, no border, no change
   in size or line height, so a link line sits in the stanza exactly like
   the plain lines around it. */
.info-link a {
  color: var(--link);
  transition: color 0.2s var(--ease);
}

.info-link a:focus-visible {
  color: var(--link-hover);
}

@media (hover: hover) and (pointer: fine) {
  .info-link a:hover {
    color: var(--link-hover);
  }
}

/* ---------- Copy-to-clipboard contact lines ---------- */

/* Email and Phone are buttons, not links: tapping them puts the value on
   the clipboard instead of launching a mail client or a dialler. The
   button is stripped back to nothing so it sits in the stanza exactly
   like the LinkedIn link beside it — same colour, size and line height,
   no box of its own. Everything here is a reset except the colour and
   the transition, which are copied from `.info-link a`. */
.copy-link {
  margin: 0;
  padding: 0;
  border: none;
  background: none;
  font: inherit;
  line-height: inherit;
  color: var(--link);
  cursor: pointer;
  /* Kills the double-tap zoom delay and the grey flash on iOS, so the
     copy lands on the first tap. */
  touch-action: manipulation;
  -webkit-tap-highlight-color: transparent;
  transition: color 0.2s var(--ease);
}

.copy-link:focus-visible {
  color: var(--link-hover);
}

/* Same touch caveat as everywhere else on the page: on a touch screen
   `:hover` latches onto the last-tapped element and stays, so a copied
   line would sit in the hover colour indefinitely. */
@media (hover: hover) and (pointer: fine) {
  .copy-link:hover {
    color: var(--link-hover);
  }
}

/* The confirmation. The label text is swapped to "Copied!" for ~1.6s;
   this only carries the colour shift, so the change reads as a
   deliberate state rather than as a wobble in the line. The label is
   restored by the same timer that removes the class. */
.copy-link.is-copied {
  color: var(--text-secondary);
}

/* Failure state — clipboard access refused (a non-secure origin, or a
   browser that blocks it outright). The value is dropped into the label
   instead so it can at least be selected by hand, and the line is
   allowed to wrap and become selectable for exactly that reason. */
.copy-link.is-manual {
  color: var(--text);
  cursor: text;
  user-select: text;
  -webkit-user-select: text;
}

/* Footer */

/* The bottom padding is the last thing in the document, so it is pure
   scrollable emptiness — and on a page this short it was most of the
   reason the page scrolled at all. It was a flat 56px, which stood in
   for the iOS home indicator; but the meta viewport is `viewport-fit=
   cover`, so the real inset is available as env(safe-area-inset-bottom)
   and can be asked for directly instead of guessed at. max() keeps a
   24px floor everywhere the inset is 0 (desktop, Android, and Safari
   whenever its own toolbar already covers the indicator), and yields to
   the real inset on a device that needs more. Net effect: 32px less
   dead space under the footer on every screen, and at 390×844 the page
   now fits without scrolling at all. */
.site-footer {
  padding: 32px 28px max(24px, env(safe-area-inset-bottom));
  text-align: center;
  font-size: 12px;
  color: var(--text-secondary);
}

/* Inline-block so each half wraps as a unit — the credit breaks away
   from the sign-off rather than stranding "Code." on its own line. No
   margin between them: the newline in the markup already collapses to
   exactly one word space, which is the right gap after a full stop. */
.footer-part {
  display: inline-block;
}

/* The `</>` reads as a mark rather than a typo: a mono face gives the
   angle brackets even weight and stops the slash colliding with them at
   12px. Slightly lighter than the words around it so it sits in the
   line as a glyph, not as emphasis. */
.code-mark {
  font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas,
    "Liberation Mono", monospace;
  font-size: 0.95em;
  letter-spacing: -0.02em;
  opacity: 0.75;
}

/* ---------- Password gate — in-header field + veil over the content ---------- */

/* THE VEIL.
   It no longer holds the password field (that lives in the mast now) and
   it is no longer sized to the *visual* viewport. Tying its top/height
   to visualViewport meant that anything which changed that viewport —
   elastic/rubber-band scrolling, a pinch, the mobile URL bar collapsing,
   the keyboard opening — moved the veil a few pixels and briefly exposed
   an unblurred band of the page underneath. `position: fixed; inset: 0`
   is anchored to the layout viewport and simply cannot drift, so the
   content behind it stays covered no matter what the viewport does.

   A veil, not a wall: the page underneath stays legible enough to see
   that there IS a page — the gate reads as "not yet" rather than as an
   opaque splash screen — and lifting it reveals what was already there. */
.lock-overlay {
  position: fixed;
  inset: 0;
  z-index: 60;
  background: rgba(251, 251, 253, 0.55);
  backdrop-filter: blur(3px) saturate(115%);
  -webkit-backdrop-filter: blur(3px) saturate(115%);
  transition: opacity 0.6s var(--ease), backdrop-filter 0.6s var(--ease);
}

/* Where backdrop-filter isn't supported the tint is the ONLY thing
   standing between a visitor and the content, and a 55% wash over text
   leaves it perfectly readable. Make the veil near-opaque there instead
   of quietly leaking the whole page. */
@supports not ((backdrop-filter: blur(3px)) or (-webkit-backdrop-filter: blur(3px))) {
  .lock-overlay {
    background: rgba(251, 251, 253, 0.94);
  }
}

html.is-unlocked .lock-overlay {
  opacity: 0;
  backdrop-filter: blur(0px);
  -webkit-backdrop-filter: blur(0px);
  pointer-events: none;
}

/* Everything the veil sits above should be inert while locked, so a
   keyboard or trackpad user can't reach it underneath the blur — and so
   text underneath can't be selected, copied, or read out. */
html.is-locked main,
html.is-locked .site-footer {
  pointer-events: none;
  user-select: none;
  -webkit-user-select: none;
}

/* ---------- The slot in the mast that holds the field ---------- */

/* The field is part of the header, directly under the name, rather than
   a free-floating element centred in the viewport. That's what pins it:
   the mast is the one block on the page that never moves, and the mast
   is above the veil in the stacking order, so the field is crisp while
   everything below it is blurred. */
.gate-slot {
  display: flex;
  /* A column now: the field, then the "no password?" hint under it. The
     form keeps its own max-width, so centring the column's items leaves
     the pill exactly where it was. */
  flex-direction: column;
  align-items: center;
  justify-content: center;
  padding: 22px 24px 2px;
  max-height: 160px;
  transition: opacity 0.45s var(--ease), transform 0.45s var(--ease),
    max-height 0.5s var(--ease), padding 0.5s var(--ease);
}

.gate-slot[hidden] {
  display: none;
}

/* The entrance animation lives HERE, on the slot — deliberately NOT on
   the pill itself.
   THE BUG IT FIXES: `rise` and `shake` were both on .password-pill via
   the `animation` shorthand. Adding .shake replaced `rise`; removing
   .shake at the end of the shake made `rise` a *newly applied*
   animation, so the browser played it again — and `rise` translates the
   element 8px vertically. That replay is the pill (and with it the text
   caret) jumping up and down after every wrong password. Splitting the
   two onto different elements means neither can ever restart the other. */
html.is-locked .gate-slot {
  animation: rise 0.5s var(--ease) both;
}

/* Unlocking: the field fades and collapses out of the header, and the
   content below slides up into the space it leaves. */
html.is-unlocked .gate-slot {
  opacity: 0;
  transform: translateY(-8px);
  max-height: 0;
  padding-top: 0;
  padding-bottom: 0;
  overflow: hidden;
  pointer-events: none;
}

/* The field, modelled on a modern chat composer: one clear rounded
   rectangle, a hairline outline, a shadow soft enough that it lifts off
   the page rather than sitting on it. No gradient fill, no glass sheen,
   no blue — the previous pill had all three, which is what made it the
   loudest thing on an otherwise quiet page. */
.password-pill {
  position: relative;
  display: flex;
  align-items: center;
  gap: 6px;
  width: 100%;
  max-width: 340px;
  padding: 9px 9px 9px 18px;
  border-radius: 16px;
  background: var(--bg-elevated);
  border: 1px solid var(--field-border);
  box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04), 0 8px 24px rgba(0, 0, 0, 0.05);
  /* Stops the double-tap-to-zoom delay on touch without disabling
     pinch-zoom for the page as a whole. */
  touch-action: manipulation;
  transition: border-color 0.2s var(--ease), box-shadow 0.2s var(--ease);
}

.password-pill:hover {
  border-color: rgba(0, 0, 0, 0.16);
}

.password-pill:focus-within {
  border-color: var(--field-border-focus);
  box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05), 0 10px 28px rgba(0, 0, 0, 0.08);
}

@keyframes rise {
  from {
    opacity: 0;
    transform: translateY(8px);
  }
  to {
    opacity: 1;
    transform: translateY(0);
  }
}

/* Wrong password. This shake plus the memoji's frown is the whole error
   state — there's no message underneath the field, by design.
   Strictly horizontal, and the 0%/100% frames are pinned to
   translateX(0) so the pill starts and ends exactly where it lives. */
.password-pill.shake {
  animation: shake 0.4s ease both;
}

@keyframes shake {
  0%, 100% { transform: translateX(0); }
  10%, 90% { transform: translateX(-1px); }
  20%, 80% { transform: translateX(2px); }
  30%, 50%, 70% { transform: translateX(-4px); }
  40%, 60% { transform: translateX(4px); }
}

.pill-input {
  flex: 1;
  min-width: 0;
  border: none;
  background: transparent;
  outline: none;
  padding: 0;
  /* 16px is load-bearing on iOS: anything smaller makes Safari zoom the
     whole page in when the field takes focus. */
  font-size: 16px;
  line-height: 1.4;
  color: var(--text);
}

.pill-input::placeholder {
  color: var(--text-secondary);
}

/* Eye toggle — swaps the password field between masked and plain text.
   Two stacked icons (open/closed eye); the button's .is-visible class,
   set in app.js, picks which one shows. */
.pill-toggle {
  width: 32px;
  height: 32px;
  border-radius: 10px;
  flex-shrink: 0;
  display: flex;
  align-items: center;
  justify-content: center;
  border: none;
  background: transparent;
  color: var(--text-secondary);
  cursor: pointer;
  opacity: 0.65;
  touch-action: manipulation;
  transition: opacity 0.15s var(--ease), background 0.15s var(--ease);
}

.pill-toggle:hover {
  opacity: 1;
  background: rgba(0, 0, 0, 0.04);
}

.pill-toggle:active {
  background: rgba(0, 0, 0, 0.07);
}

.pill-toggle .icon-closed {
  display: none;
}

.pill-toggle.is-visible .icon-open {
  display: none;
}

.pill-toggle.is-visible .icon-closed {
  display: block;
}

/* ---------- Short-viewport / keyboard-open compaction ---------- */

/* With the field in the header, the header is what has to fit above the
   mobile keyboard — and the priority while the gate is up is that the
   memoji, the name AND the field all stay on screen together.

   Two tiers, set by app.js. .is-compact goes on as soon as the field
   takes focus on a phone-sized screen — BEFORE the keyboard animates in,
   so the header is already small by the time the browser decides whether
   it needs to shove anything out of the way. .is-compact-xs is the
   fallback for genuinely tiny visible bands (a landscape phone with the
   keyboard up), where even the compact header wouldn't fit. */
html.is-compact .mast {
  padding-top: 20px;
  padding-bottom: 0;
}

html.is-compact .memoji {
  --memoji-size: 78px;
  margin-bottom: 4px;
}

html.is-compact .site-name {
  font-size: 27px;
}

html.is-compact .gate-slot {
  padding-top: 14px;
}

html.is-compact-xs .mast {
  padding-top: 10px;
}

html.is-compact-xs .memoji {
  --memoji-size: 52px;
  margin-bottom: 2px;
}

html.is-compact-xs .site-name {
  font-size: 21px;
}

html.is-compact-xs .gate-slot {
  padding-top: 10px;
}

/* ---------- "No password? Get in touch." ---------- */

/* An offer, not an instruction — so it is deliberately quieter than
   everything around it: 13px against the field's 16px, secondary grey,
   centred under the pill. 13px/#6e6e73 on the page background still
   clears AA for body text (~4.8:1), so "small" doesn't cost legibility.
   The 10px top margin keeps it attached to the field rather than
   floating between the field and the page below it. */
.gate-hint {
  margin: 10px 0 0;
  font-size: 13px;
  line-height: 1.45;
  color: var(--text-secondary);
  text-align: center;
}

/* The link half is a .copy-link, so it inherits this line's 13px through
   `font: inherit` and picks up the same indigo as every other link on
   the page. Nothing else needed here. */

/* On a genuinely tiny visible band — a landscape phone with the keyboard
   up — the hint is the first thing to go: the memoji, the name and the
   field all staying on screen together matters more than an aside. */
html.is-compact-xs .gate-hint {
  display: none;
}

/* Compaction is a resize of real layout, so it's animated — snapping the
   memoji to a smaller size the instant the keyboard opens is a jolt. */
.memoji,
.site-name {
  transition: width 0.22s var(--ease), height 0.22s var(--ease),
    margin 0.22s var(--ease), font-size 0.22s var(--ease);
}

.mast {
  transition: transform 0.2s var(--ease), padding 0.22s var(--ease);
}

/* While the visual viewport is actively moving — which on a phone means
   the keyboard animating in or out — the mast's transform is rewritten
   every frame to follow it. Easing a property that is re-set every frame
   is the stutter: each new value restarts a 200ms curve from wherever
   the previous one had got to, so the header slides, hesitates and
   slides again instead of staying put relative to the visible band.
   Dropping the transform easing for the duration makes the mast track
   1:1, which reads as the header being nailed in place. The padding
   easing stays — that one is only ever changed by compaction, which is
   a real, deliberate resize. */
html.is-vv-live .mast {
  transition: padding 0.22s var(--ease);
}

@media (max-width: 720px) {
  .mast {
    padding: 24px 24px 4px;
  }

  .memoji {
    margin-bottom: 12px;
  }

  .section {
    padding: 14px 24px;
  }
}

/* ---------- Expandable "What" items ---------- */

/* Each item is its own disclosure: opening one never touches another.
   The trigger is styled to sit in the stanza exactly like the plain
   lines around it — same size, weight and line height — so the only
   things marking it as interactive are the chevron and the colour
   shift, which is the same affordance the links use. */
.disclosure-trigger {
  display: flex;
  align-items: center;
  gap: 8px;
  width: 100%;
  margin: 0;
  padding: 0;
  border: none;
  border-radius: 6px;
  background: none;
  color: var(--text);
  font-size: 17px;
  line-height: 1.45;
  text-align: left;
  cursor: pointer;
  /* Kills the double-tap zoom delay and the grey flash on iOS, so the
     panel opens on the first tap. */
  touch-action: manipulation;
  -webkit-tap-highlight-color: transparent;
  transition: color 0.2s var(--ease);
}

/* Hover is gated on a device that actually has a pointer. On touch,
   `:hover` latches onto the last-tapped element and stays there until
   something else is tapped — which is why a "What" title stayed blue
   after its panel had been closed again. */
@media (hover: hover) and (pointer: fine) {
  .disclosure-trigger:hover {
    color: var(--link);
  }
}

.disclosure-trigger:focus-visible {
  outline: 2px solid var(--field-border-focus);
  outline-offset: 4px;
}

.disclosure-chevron {
  flex-shrink: 0;
  color: var(--text-secondary);
  transform-origin: 50% 55%;
  transition: transform 0.34s var(--ease), color 0.2s var(--ease);
}

@media (hover: hover) and (pointer: fine) {
  .disclosure-trigger:hover .disclosure-chevron {
    color: var(--link);
  }
}

/* An open item's title goes BOLD. It used to go blue, and that stopped
   working the moment the panels gained work-sample links: inside an open
   panel, blue now means one specific thing — text that opens a sample —
   and spending the same colour on the section title above it collapsed
   two ranks into one. Weight separates them cleanly. The title is the
   heading; the blue is the thing you can click inside it.

   The job is the same as before. The chevron alone used to carry the
   whole open/closed signal, but on a long page — and especially on a
   phone, where an expanded panel can push its own trigger off-screen —
   the state has to be findable at a glance on the way back up. Weight
   reads at that distance as well as colour did.

   Deliberately NOT inside the `hover: hover` guard: it is driven by the
   .is-open class, not by pointer state, so it is correct on touch and
   clears the moment the panel closes. Hover is untouched and still goes
   to the link colour — that is transient, pointer-only, and it is the
   only thing telling a mouse that the title is a control at all. */
.disclosure.is-open .disclosure-trigger {
  font-weight: 600;
}

/* The chevron comes up to the title's own colour rather than staying a
   step back, so the open row reads as one object. */
.disclosure.is-open .disclosure-chevron {
  color: var(--text);
}

/* The label reserves its BOLD width at every moment, so opening an item
   cannot shove the chevron sideways. The ::after carries the same string
   at the open weight in a zero-height second row of the same flex
   column: the column is therefore always as wide as the bold text will
   ever be, while the copy contributes nothing to the height and nothing
   to the accessibility tree. Needs `data-label` on the span to mirror.

   Without this the chevron jumps 5-7px on every toggle, which on three
   stacked items reads as the page flinching. */
.disclosure-label {
  display: inline-flex;
  flex-direction: column;
  align-items: flex-start;
  min-width: 0;
}

.disclosure-label::after {
  content: attr(data-label);
  font-weight: 600;
  height: 0;
  overflow: hidden;
  visibility: hidden;
  pointer-events: none;
  user-select: none;
  -webkit-user-select: none;
}

.disclosure.is-open .disclosure-chevron {
  transform: rotate(180deg);
}

/* The panel is animated with a 0fr -> 1fr grid row rather than a
   max-height guess. The row resolves to the content's real height, so an
   open panel is never clipped and a close never has dead time at the end
   of the transition while an over-large max-height unwinds.
   `visibility` is switched too (delayed on the way out until the
   collapse has finished) so a closed panel's copy is genuinely gone —
   not merely invisible — to screen readers and find-in-page. */
.disclosure-panel {
  display: grid;
  grid-template-rows: 0fr;
  opacity: 0;
  visibility: hidden;
  transition: grid-template-rows 0.34s var(--ease), opacity 0.22s var(--ease),
    visibility 0s linear 0.34s;
}

.disclosure.is-open .disclosure-panel {
  grid-template-rows: 1fr;
  opacity: 1;
  visibility: visible;
  transition: grid-template-rows 0.34s var(--ease),
    opacity 0.3s var(--ease) 0.05s, visibility 0s;
}

/* min-height: 0 is load-bearing — without it the grid row refuses to go
   below the content's min-content height and the panel never closes. */
.disclosure-panel > .disclosure-inner {
  overflow: hidden;
  min-height: 0;
}

/* Secondary size and colour: the expanded copy is supporting detail, so
   it sits a step back from the headline items it hangs off. */
.info-lines .disclosure-intro {
  margin: 10px 0 0;
  font-size: 15.5px;
  line-height: 1.55;
  color: var(--text-secondary);
  max-width: 58ch;
}

.disclosure-list {
  margin: 9px 0 0;
  /* The bottom space belongs to the LIST, not to .disclosure-inner —
     see the note above about padding surviving a 0fr track. */
  padding: 0 0 16px 16px;
  list-style: none;
  max-width: 58ch;
}

.disclosure-list li {
  position: relative;
  margin: 0 0 6px;
  font-size: 15.5px;
  line-height: 1.55;
  color: var(--text-secondary);
}

.disclosure-list li:last-child {
  margin-bottom: 0;
}

/* Each bullet leads with its result and then explains how it was done.
   The lead is set at the page's own text colour and the same 600 the
   open disclosure title uses, so a panel can be skimmed on the results
   alone — and so the hierarchy inside a panel stays weight-based, which
   is what keeps blue meaning only "this opens a work sample". 700 was
   too loud beside 15.5px secondary copy; 600 reads as a lead-in rather
   than as shouting. */
.disclosure-list li strong {
  font-weight: 600;
  color: var(--text);
}

.disclosure-list li::before {
  content: "";
  position: absolute;
  left: -14px;
  top: 0.66em;
  width: 4px;
  height: 4px;
  border-radius: 50%;
  background: rgba(0, 0, 0, 0.28);
}

@media (prefers-reduced-motion: reduce) {
  .disclosure-panel,
  .disclosure.is-open .disclosure-panel {
    transition: opacity 0.15s linear, visibility 0s;
  }

  .disclosure-chevron {
    transition: color 0.2s linear;
  }
}

/* A half-line of air between the last expandable item and the resume
   link. The three disclosures are one group and the resume is a
   different kind of thing — a destination, not something that opens in
   place — so it needs separating, but a full blank line (24px) floated
   it away from the group entirely. 12px reads as "still part of What,
   just not one of the three". The specificity has to beat
   `.info-lines p { margin: 0 }`, hence the leading .info-lines. */
.info-lines .disclosure + .info-link {
  margin-top: 12px;
}

/* ---------- Tap the memoji ---------- */

/* Only once the page is unlocked — behind the veil the face isn't
   something to poke at. `-webkit-touch-callout` is what stops iOS
   offering to save the video on a long press, and the tap highlight is
   suppressed because the scale-down below is the feedback. */
html.is-unlocked .memoji {
  cursor: pointer;
  user-select: none;
  -webkit-user-select: none;
  -webkit-touch-callout: none;
  -webkit-tap-highlight-color: transparent;
}

.memoji {
  transition: width 0.22s var(--ease), height 0.22s var(--ease),
    margin 0.22s var(--ease), transform 0.18s var(--ease);
}

html.is-unlocked .memoji:active {
  transform: scale(0.965);
}

@media (prefers-reduced-motion: reduce) {
  html.is-unlocked .memoji:active {
    transform: none;
  }
}
