/* ============================================================
   dylanbertalli.com
   Intro → horizontal work gallery → detail view.
   No framework, no build step. Devices are drawn in CSS.
   ============================================================ */

/* ── Inter, self-hosted ──────────────────────────────────────
   Variable files, so one download covers weights 300–800. The unicode-range
   split means latin-ext is only fetched if a character actually needs it —
   English copy never pays for it. */

@font-face {
  font-family: "Inter";
  font-style: normal;
  font-weight: 300 800;
  font-display: swap;
  src: url("assets/fonts/inter-latin.woff2") format("woff2");
  unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA,
                 U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122,
                 U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}

@font-face {
  font-family: "Inter";
  font-style: normal;
  font-weight: 300 800;
  font-display: swap;
  src: url("assets/fonts/inter-latin-ext.woff2") format("woff2");
  unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF,
                 U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF,
                 U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}

:root {
  --bg:        #f9f9f7;
  --ink:       #0b0b0b;
  --ink-soft:  #55554f;
  --ink-faint: #a8a7a0;
  --rule:      #dedcd4;

  --sans: "Inter", ui-sans-serif, system-ui, -apple-system, "Segoe UI",
          Roboto, "Helvetica Neue", Arial, sans-serif;

  /* One easing for every large move. Consistency is what reads as "smooth". */
  --ease: cubic-bezier(0.22, 1, 0.36, 1);
  --move: 950ms;

  color-scheme: light only;
}

*, *::before, *::after { box-sizing: border-box; }

/* Buttons don't inherit font-family from body by default — they fall back to
   the UA font. Nothing in them is text today, but this keeps it true. */
button, input, select, textarea { font: inherit; color: inherit; }

html, body { height: 100%; }

body {
  margin: 0;
  background: var(--bg);
  color: var(--ink);
  font-family: var(--sans);
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
  overflow: hidden;
}

.sr-only {
  position: absolute;
  width: 1px; height: 1px;
  padding: 0; margin: -1px;
  overflow: hidden;
  clip: rect(0 0 0 0);
  white-space: nowrap;
  border: 0;
}

/* No focus rings anywhere, by request. Focus is still tracked and moved for
   keyboard users so Tab, Enter and Escape all behave — it is simply never
   drawn. Nothing here should render a border or outline on focus. */
:focus, :focus-visible { outline: none; }
.item-hit, .info-btn, .back-btn, .close-btn, .overlay { -webkit-tap-highlight-color: transparent; }

/* ════════════════════ INTRO ════════════════════ */

.intro {
  position: fixed;
  inset: 0;
  z-index: 40;
  display: grid;
  place-items: center;
  background: var(--bg);
  /* Quick. A long fade here overlaps the gallery's own fade and you spend
     over a second watching washed-out grey devices under the word. */
  transition: opacity 550ms var(--ease), transform 900ms var(--ease);
}
.intro.is-gone {
  opacity: 0;
  transform: translateY(-10px) scale(.988);
  pointer-events: none;
}

.word {
  margin: 0;
  font-weight: 300;
  font-size: clamp(2.75rem, 8.5vw, 7rem);
  line-height: 1;
  letter-spacing: -0.035em;
  white-space: nowrap;
}
.word-type { display: inline-flex; align-items: baseline; }

/* The blink is driven from script.js with the Web Animations API so it can
   finish a whole number of cycles and resolve to solid — a CSS animation
   cancelled by a timer snaps mid-cycle, which is what made it jump. */
.caret {
  display: inline-block;
  width: 2px;
  height: .96em;
  margin-left: .07em;
  background: var(--ink);
  transform: translateY(.1em);
  opacity: 1;
}
.caret.is-done { opacity: 0; transition: opacity 700ms var(--ease); }

/* ════════════════════ CHROME ════════════════════ */

.head {
  position: fixed;
  top: clamp(1.5rem, 4vh, 2.75rem);
  left: clamp(1.5rem, 5vw, 4rem);
  z-index: 20;
  opacity: 0;
  transition: opacity 1000ms var(--ease);
}
.head.is-visible { opacity: 1; }
body.is-detail .head { opacity: 0; pointer-events: none; }

.head-id { display: grid; gap: .12rem; }

/* The project leads. Large, tightly tracked, and quiet — the size does the
   work rather than weight or colour. */
.head-project {
  font-size: clamp(1.375rem, 2.35vw, 2.125rem);
  font-weight: 600;
  letter-spacing: -0.038em;
  line-height: 1.04;
  color: var(--ink);
  /* Named projects cross-fade as the rail moves; a hard swap at this size
     reads as a glitch. */
  transition: opacity 200ms var(--ease), transform 260ms var(--ease);
}
.head-project.is-swapping { opacity: 0; transform: translateY(5px); }

.head-by {
  font-size: clamp(.875rem, 1.05vw, 1.0625rem);
  font-weight: 400;
  letter-spacing: -0.012em;
  line-height: 1.35;
  color: var(--ink-faint);
}

.info-btn, .back-btn {
  position: fixed;
  top: clamp(1.5rem, 4vh, 2.75rem);
  padding: 0; border: 0;
  background: none;
  cursor: pointer;
  z-index: 20;
  opacity: 0;
  pointer-events: none;
  transition: opacity 1000ms var(--ease), transform .25s ease, color .2s ease;
}

/* Sized to the byline so it doesn't compete with the name of the work, but
   set in the title's ink so it reads as a control rather than a caption. */
.info-btn {
  right: clamp(1.5rem, 5vw, 4rem);
  color: var(--ink);
  font-size: clamp(.875rem, 1.05vw, 1.0625rem);
  letter-spacing: -0.012em;
  line-height: 1.35;
}
.info-btn:hover { opacity: .6; }
.info-btn.is-visible { opacity: 1; pointer-events: auto; }
/* About stays put while a project is open - it is the only way out to the
   overlay from the detail view, and the back arrow already owns the top
   left. It sits outside .gallery, so clicking it cannot reach the
   click-anywhere-to-dismiss handler. */

.back-btn {
  left: clamp(1.5rem, 5vw, 4rem);
  width: 30px; height: 30px;
  color: var(--ink);
}
.back-btn svg { width: 100%; height: 100%; display: block; }
body.is-detail .back-btn { opacity: 1; pointer-events: auto; transition-delay: 180ms; }
.back-btn:hover { transform: translateX(-3px); }

/* ════════════════════ GALLERY ════════════════════ */

.gallery {
  position: fixed;
  inset: 0;
  display: flex;
  align-items: center;
  overflow: hidden;
  opacity: 0;
  transition: opacity 700ms var(--ease);
  cursor: grab;
}
.gallery.is-visible { opacity: 1; }
.gallery.is-dragging { cursor: grabbing; }
/* Open state is click-to-dismiss, so the whole stage stays a pointer. */
body.is-detail .gallery { cursor: pointer; }

.track {
  display: flex;
  align-items: center;
  gap: clamp(3.5rem, 7vw, 8rem);
  padding-inline: clamp(2rem, 18vw, 22rem);
  will-change: transform;
}

.item {
  position: relative;
  z-index: 1;
  flex: 0 0 auto;
  transition: opacity 600ms var(--ease), transform 800ms var(--ease);
}

/* The opened device must sit above its siblings while they fade — later
   items in the DOM would otherwise paint over it on the way out. */
.item.is-active { z-index: 4; }

/* Before the reveal the devices sit low and invisible. */
.gallery:not(.is-visible) .item { opacity: 0; transform: translateY(22px); }

/* Stagger applies only during the entrance, then the class is dropped so
   later fades (opening a project) aren't delayed by it. */
.gallery.is-entering .item {
  transition-delay: calc(160ms + var(--i, 0) * 70ms);
}

body.is-detail .item:not(.is-active) { opacity: 0; pointer-events: none; }

/* Full-tile hit area, so the whole device is clickable without
   wrapping the mockup markup in a button. */
/* Layer order inside an item, lowest first:
     1  item-hit    click anywhere on the device to open it
     2  screen      sits above the hit area so a live embed gets the events
     3  frame       bezel art must stay on top to mask the display
   The frame is pointer-events:none, so clicks on the bezel fall through to
   the hit area beneath it. */
.item-hit {
  position: absolute;
  inset: -1.5rem;
  z-index: 1;
  border: 0;
  padding: 0;
  background: transparent;
  cursor: pointer;
}
body.is-detail .item-hit { pointer-events: none; }

.device {
  position: relative;
  /* will-change makes this its own stacking context, so an explicit z-index
     is needed to lift it (and the live embed inside) above .item-hit. */
  z-index: 2;
  transform-origin: 50% 50%;
  transition: transform var(--move) var(--ease);
  /* No permanent will-change here. A device only transforms while opening or
     closing; holding four of them on their own compositing layers for the
     whole session is GPU memory spent for nothing, and layer pressure is one
     of the things that pushes video onto a slower paint path. */
  /* Transparent to pointers so the bezel, base and mock screens all fall
     through to .item-hit beneath. A live embed re-enables itself. */
  pointer-events: none;
}

/* Promoted only for the duration of the move. */
body.is-detail .item.is-active .device { will-change: transform; }

/* ════════════════════ DEVICES ════════════════════
   Apple's official bezel art (MacBook Air M5, iPhone 17), with the screen
   cutout transparent. Screen content sits *behind* the frame and shows
   through the hole, so the frame's own rounded corners do the masking.

   Cutout rects were measured off the alpha channel, not eyeballed:
     MacBook  2880x1864 screen inside 3540x2300  →  9.294% 9.478% 81.356% 81.043%
     iPhone   1206x2622 screen inside 1350x2760  →  5.333% 2.500% 89.333% 95.000%
   Both are the devices' true native resolutions.                          */

.device-frame {
  display: block;
  width: 100%;
  height: auto;
  position: relative;
  z-index: 3;
  pointer-events: none;
  -webkit-user-drag: none;
  user-select: none;
}

.device-screen {
  /* One source of truth for the screen colour. The bleed below must always
     match the background — set them separately and a dark screen ends up
     ringed in white. Override --screen-bg per project, never `background`. */
  --screen-bg: #fff;

  position: absolute;
  z-index: 2;
  overflow: hidden;
  background: var(--screen-bg);
  /* Bleed the screen colour a little past its own box. The bezel's cutout is
     anti-aliased and this rect lands on fractional pixels, so without the
     bleed you see the page background through the seam as a white hairline.
     The overspill is covered by the opaque bezel. */
  box-shadow: 0 0 0 2px var(--screen-bg);
  container-type: size;
  /* Mock screens must not eat clicks meant to open the project. Only a live
     embed opts back in. */
  pointer-events: none;
}

/* Any strip left above an embed reads as part of the page, not a gap. */
.device-screen--dark { --screen-bg: #000; }

/* The live site, mounted only while its project is open. Laid out at a real
   desktop width so it renders its desktop breakpoint, then scaled to fit —
   script.js sets the scale from the screen's layout width. */
.device-site {
  position: absolute;
  top: 0; left: 0;
  /* Wider than the layout width used for scaling, so the embedded page's
     scrollbar lands in the strip the screen crops off. */
  width: 1226px;
  /* A placeholder only. sizeSiteFrames measures the screen and sets the
     real height, so the iframe's bottom edge and the screen's bottom edge
     are the same line and a scrolled page can reach its own footer. The
     frame is scale(0) until that runs, so this value is never seen. */
  height: 880px;
  border: 0;
  transform-origin: 0 0;
  transform: scale(0);        /* nothing visible until measured */
  background: #000;
  color-scheme: normal;
  z-index: 5;                 /* above the recording it replaces */
  pointer-events: none;
}

/* Unmounted: out of the paint entirely. Left visible it renders an empty
   document over the recording — a black rectangle where the site should be. */
.device-site:not([src]) { display: none; }

/* While a live site is up, nothing else on that screen should render. The
   embed is nudged down to clear the bezel notch, so a placeholder left
   underneath shows through the strip along the top. */
.device-screen.is-live > .ui,
.device-screen.is-live > .device-video,
.device-screen.is-live > .device-still { visibility: hidden; }

/* Live and browsable once its project is open. */
body.is-detail .item.is-active .device-site { pointer-events: auto; }

/* ════════════════════ DEVICE SIZES ════════════════════ */

/* The vh cap stops a device outgrowing a short window — the Mac is 0.65x as
   tall as it is wide, the phone 2.04x. */
.mac { width: min(clamp(23rem, 46vw, 44rem), 92vh); }
.mac .device-screen {
  left: 9.294%;
  top: 9.478%;
  width: 81.356%;
  height: 81.043%;
  border-radius: .7% / 1.1%;
}

.phone { width: min(clamp(10.5rem, 17vw, 16rem), 34vh); }
.phone .device-screen {
  left: 5.333%;
  top: 2.5%;
  width: 89.333%;
  height: 95%;
  /* The display's real corner radius, measured off the alpha channel:
     ~190px on a 1206px-wide screen. Rounded up a hair — the surplus hides
     under the frame, whereas too little leaks square corners past it. */
  border-radius: 16% / 7.4%;
}

/* ════════════════════ SCREEN CONTENT ════════════════════ */
/* Sized in cqw so the mock UI scales with the device, not the page. */

.ui { height: 100%; background: #fff; }

.ui--desktop { display: flex; font-size: 2.6cqw; }

.ui-side {
  width: 27%;
  padding: 5.5cqh 3cqw;
  background: var(--accent-soft);
  border-right: .3cqw solid rgba(0,0,0,.05);
  display: flex;
  flex-direction: column;
  gap: 2.2cqh;
}
.ui-title { display: block; height: 2.6cqh; width: 72%; background: rgba(0,0,0,.62); border-radius: 99px; }
.ui-row   { display: block; height: 2.2cqh; width: 88%; background: rgba(0,0,0,.13); border-radius: 99px; }
.ui-row.is-on { background: var(--accent); width: 96%; }

.ui-meters { display: flex; flex-direction: column; gap: 1.6cqh; margin-top: 1.5cqh; }
.ui-meters span {
  display: block; height: 1.3cqh; border-radius: 99px;
  background: linear-gradient(90deg, var(--accent) 0 68%, #e8534f 68% 100%);
}

.ui-main { flex: 1; padding: 5cqh 3.4cqw; display: flex; flex-direction: column; gap: 3cqh; }
.ui-bar { display: flex; align-items: center; justify-content: space-between; }
.ui-h    { display: block; height: 2.8cqh; width: 34%; background: rgba(0,0,0,.72); border-radius: 99px; }
.ui-pill { display: block; height: 3.4cqh; width: 13%; background: rgba(0,0,0,.09); border-radius: 99px; }

.ui-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 1.6cqw; }
.ui-grid i {
  display: block;
  aspect-ratio: 1;
  border-radius: .9cqw;
  background: var(--accent-soft);
  border: .25cqw solid color-mix(in srgb, var(--accent) 30%, transparent);
}
.ui-grid i:nth-child(4n+2) { background: color-mix(in srgb, var(--accent) 22%, #fff); }
.ui-grid i:nth-child(7)    { background: color-mix(in srgb, var(--accent) 42%, #fff); }

.ui-chart { flex: 1; display: flex; align-items: flex-end; gap: 1.8cqw; }
.ui-chart i {
  flex: 1;
  height: var(--v);
  border-radius: .6cqw .6cqw 0 0;
  background: linear-gradient(180deg, var(--accent) 0%, color-mix(in srgb, var(--accent) 45%, #fff) 100%);
}

/* Mobile screen */
.ui--mobile { display: flex; flex-direction: column; font-size: 5cqw; }
.ui-hero {
  height: 34%;
  background:
    radial-gradient(120% 90% at 30% 20%, color-mix(in srgb, var(--accent) 65%, #fff) 0%, transparent 60%),
    linear-gradient(160deg, var(--accent) 0%, color-mix(in srgb, var(--accent) 40%, #111) 100%);
}
.ui-hero.is-tall { height: 46%; }

.ui-lines { padding: 4.5cqh 6cqw 0; display: flex; flex-direction: column; gap: 1.8cqh; }
.ui-lines .ui-h { height: 2.4cqh; width: 62%; }
.ui-lines span  { display: block; height: 1.5cqh; width: 100%; background: rgba(0,0,0,.11); border-radius: 99px; }
.ui-lines span.is-short { width: 55%; }

.ui-cards { margin-top: auto; padding: 0 6cqw 6cqh; display: flex; flex-direction: column; gap: 2cqh; }
.ui-cards i {
  display: block; height: 8.5cqh;
  border-radius: 3cqw;
  background: var(--accent-soft);
  border: .6cqw solid color-mix(in srgb, var(--accent) 26%, transparent);
}

/* ════════════════════ DETAIL PANEL ════════════════════ */

.detail {
  position: fixed;
  z-index: 15;
  top: 50%;
  left: clamp(1.25rem, 6.5vw, 12rem);
  /* Sized in vw, because the gap before the device tracks the viewport. A
     fixed max would either wrap the title on wide screens or collide with
     the device on narrow ones.

     Both the panel and the Mac grew 15%, which uses up most of the middle —
     hence the shorter left offset above, to keep them from meeting. One width
     serves the phone too, which had room to spare already. */
  width: min(34rem, 29.3vw);

  /* The panel is exactly as tall as the open device beside it, and shares its
     centre line. Both values come from placeDetail(), which is the only place
     that knows the device's final box — it is a scaled transform, so there is
     no CSS length that describes it. The fallbacks are what a Mac resolves to
     (77vh, centred), so the panel is right even if script.js never runs.

     PADDING, NOT MARGIN: it belongs to the scroll box, so the first and last
     lines clear the edges at both ends of the scroll rather than butting
     against them. */
  top: var(--detail-cy, 50%);
  height: var(--detail-h, 77vh);
  padding-block: 1.75rem;

  /* A project with a dozen published links outgrows the device's height, so
     the panel scrolls. `safe` matters: plain `center` pushes overflow past
     the top of a scroll container, where it can't be reached. */
  display: flex;
  flex-direction: column;
  justify-content: safe center;
  overflow-y: auto;
  overscroll-behavior: contain;
  scrollbar-width: none;
  transform: translateY(-50%) translateY(.75rem);
  opacity: 0;
  pointer-events: none;
  transition: opacity 620ms var(--ease), transform 780ms var(--ease);
}
/* While there is more below, the bottom edge softens. The panel scrolls but
   has no scrollbar, so on a phone this is the only thing that says a name
   cut in half is reachable rather than missing.

   1.5rem on purpose: a deeper fade erases most of a line, which trades one
   kind of missing content for another. It only has to make the cut look
   meant. Masked rather than covered by a gradient, because the panel is
   fixed with a geometry that comes from script and anything laid over it
   would have to be positioned twice. */
.detail.has-more {
  -webkit-mask-image: linear-gradient(to bottom, #000 calc(100% - 1.5rem), transparent);
  mask-image: linear-gradient(to bottom, #000 calc(100% - 1.5rem), transparent);
}

/* Text blocks size themselves; without this a long press list would be
   squeezed by the flex container instead of scrolling. */
.detail > * { flex-shrink: 0; }
body.is-detail .detail {
  opacity: 1;
  pointer-events: auto;
  transform: translateY(-50%);
  transition-delay: 260ms;
}

.detail-title {
  margin: 0 0 .55rem;
  font-size: clamp(1.375rem, 1.85vw, 1.8rem);
  font-weight: 700;
  line-height: 1.12;
  letter-spacing: -0.03em;
  text-wrap: balance;
}
.detail-title a {
  color: inherit;
  text-decoration: underline;
  text-underline-offset: 3px;
  text-decoration-thickness: 1px;
  transition: opacity .2s ease;
}
.detail-title a:hover { opacity: .55; }
.detail-meta {
  margin: 0;
  display: grid;
  gap: .5rem;              /* the second line is a paragraph now, not a label */
  font-size: 1.0625rem;
  font-weight: 400;
  color: var(--ink-soft);
  line-height: 1.45;
}
.detail::-webkit-scrollbar { width: 0; height: 0; }

.detail-credits {
  list-style: none;
  margin: 2.6rem 0 0;
  padding: 0;
  display: grid;
  gap: .28rem;
  font-size: 1.0625rem;
  color: var(--ink-faint);
  line-height: 1.35;
}

/* Names link to profiles where one exists. A faint underline rather than
   hover-only: hover does not exist on touch, and without a resting cue there
   is nothing to tell a phone user these are tappable at all. */
.detail-credits a {
  color: inherit;
  text-decoration: underline;
  text-decoration-color: rgba(11, 11, 11, .18);
  text-decoration-thickness: 1px;
  text-underline-offset: 3px;
  transition: color .2s ease, text-decoration-color .2s ease;
}
.detail-credits a:hover {
  color: var(--ink-soft);
  text-decoration-color: currentColor;
}

/* A screen recording filling the phone's display. Playback is driven from
   script.js — it holds on the poster in the filmstrip and plays only while
   the project is open, so motion never competes with the rail. */
.device-video,
.device-still {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  object-fit: cover;
  display: block;
  pointer-events: none;
}
.device-video { background: #000; }

/* The MacBook bezel's notch reaches 54px into a 1864px display — 2.95% — so
   laptop content is dropped clear of it.

   The strip that leaves is black on every laptop, not the site's own colour.
   The notch is painted opaque black, so against any other colour it reads as
   a hard rectangle punched into the page. Black makes it vanish, and the
   strip reads as a slightly deeper bezel rather than a gap. */
.mac .device-screen { --screen-bg: #000; }

/* The strip has to be black so the opaque notch disappears into it, but a
   flat black band meets a lighter page header as a hard line. So it is solid
   only where the notch actually is, then fades out — the band blends into
   whatever sits below it, whether that is a near-black live page or a
   lighter still. Sits above the screen contents, below the bezel. */
.mac .device-screen::after {
  content: "";
  position: absolute;
  top: 0; left: 0; right: 0;
  /* Solid across the notch (2.95%), then a short fade. Any longer and it
     dims the page header underneath instead of just blending into it. */
  height: 4.2%;
  background: linear-gradient(180deg, #000 0%, #000 74%, rgba(0,0,0,0) 100%);
  z-index: 6;
  pointer-events: none;
}

.mac .device-video,
.mac .device-still {
  top: 3.2%;
  height: 100%;
}

/* The recording captured iOS's status bar — recording pill, signal, wifi,
   battery — and scrolling content collides with it.

   The height is not arbitrary. Measured on a 1348px frame: the status bar
   ends at 60px and the app's logo begins at 78px, so the band has to land in
   that 18px gap. 5.2% (70px) covers the status bar with clearance and stops
   short of the logo. At 6.5% it was clipping the logo. Black, because the app
   behind it is black. */
.screen-topmask {
  position: absolute;
  top: 0; left: 0; right: 0;
  height: 5.2%;
  background: #000;
  z-index: 3;
  pointer-events: none;
}

/* Published work: grouped links, for a project whose output is writing and
   talks rather than a product with collaborators. */
.detail-links { margin: 1.9rem 0 0; }
.detail-links[hidden] { display: none; }
.detail-links dl { margin: 0; display: grid; gap: 1.15rem; }
.detail-links dt {
  font-size: .75rem;
  letter-spacing: .1em;
  text-transform: uppercase;
  font-weight: 700;
  color: var(--ink-soft);
}
.detail-links dd { margin: .5rem 0 0; display: grid; gap: .75rem; }

/* Headlines run full length — the panel scrolls, so there's room. The rule
   to the left is what keeps a four-line headline reading as one entry
   rather than as a paragraph. */
.detail-links a {
  position: relative;
  display: block;
  padding-left: .85rem;
  color: var(--ink-soft);
  text-decoration: none;
  font-size: .9375rem;
  line-height: 1.32;
  transition: color .2s ease;
}

/* A quiet marker, so the list reads as entries rather than a paragraph. */
.detail-links a::before {
  content: "";
  position: absolute;
  left: 1px;
  /* Optically centred on the first line rather than mathematically: a round
     marker sits low against cap-height text if you centre it on the line box. */
  top: .5em;
  width: 4px;
  height: 4px;
  border-radius: 50%;
  background: currentColor;
  opacity: .45;
  transition: opacity .22s ease, transform .22s var(--ease);
}

.detail-links a:hover { color: var(--ink); }


/* Two one-word links read badly as a dashed column - the markers outnumber
   the content. Laid out as a row instead, with the rule dropped. */
.detail-links dd.is-inline {
  display: flex;
  flex-wrap: wrap;
  gap: .35rem 1.25rem;
}
.detail-links dd.is-inline a { padding-left: 0; }
.detail-links dd.is-inline a::before { display: none; }
.detail-links a:hover::before { opacity: 1; transform: scale(1.4); }


/* ── App Store badge, under the device ── */

.detail-cta {
  position: fixed;
  z-index: 16;
  /* Both come from placeDetail. The fallbacks put it under the desktop
     device position, so it is never stranded if script.js has not run. */
  top: var(--cta-top, 62%);
  left: var(--device-cx, 66%);
  transform: translateX(-50%) translateY(.5rem);
  margin: 0;
  font-size: 1.0625rem;
  opacity: 0;
  pointer-events: none;
  transition: opacity 620ms var(--ease), transform 780ms var(--ease);
}
body.is-detail .detail-cta {
  opacity: 1;
  pointer-events: auto;
  transform: translateX(-50%);
  transition-delay: 260ms;
}
.detail-cta[hidden] { display: none; }
.detail-cta a {
  color: var(--ink);
  text-decoration: underline;
  text-underline-offset: 3px;
  text-decoration-thickness: 1px;
  transition: opacity .2s ease;
}
.detail-cta a:hover { opacity: .55; }

/* Apple's badge ships at 40px tall and must not be redrawn or restyled —
   so no underline, and it keeps its own proportions. */
.detail-cta a.is-badge {
  display: inline-block;
  text-decoration: none;
  line-height: 0;
}
.detail-cta a.is-badge img {
  height: 40px;
  width: auto;
  display: block;
}

/* ── "this one actually works" cue, under the device ── */

.live-hint {
  position: fixed;
  z-index: 15;
  bottom: clamp(1.25rem, 5vh, 3rem);
  left: 66%;                       /* under the device, which centres at 66% */
  transform: translateX(-50%) translateY(.5rem);
  margin: 0;
  white-space: nowrap;
  font-size: .9375rem;
  color: var(--ink-faint);
  opacity: 0;
  pointer-events: none;
  transition: opacity 700ms var(--ease), transform 700ms var(--ease);
}

/* Only for an open project that has something to say under its device. */
body.is-detail .live-hint.is-on {
  opacity: 1;
  transform: translateX(-50%);
  transition-delay: 620ms;         /* after the device has landed */
}



/* Where the project also has an App Store badge, the cue moves off the
   floor of the window and into the gap under the device, and the badge
   moves down to make room. --hint-drop is measured in placeDetail, which
   also shrinks the device by the height of this stack so the badge clears
   the bottom of the window. */
.live-hint.has-cta { top: var(--cta-top); bottom: auto; }
@media (min-width: 901px) {
  .detail-cta.with-hint { top: calc(var(--cta-top) + var(--hint-drop, 2.1rem)); }
}

@media (max-width: 900px) {
  /* The text panel takes the bottom on narrow screens. */
  .live-hint { display: none; }
}

/* ════════════════════ ABOUT OVERLAY ════════════════════ */

.overlay {
  position: fixed;
  inset: 0;
  background: var(--bg);
  z-index: 50;
  overflow-y: auto;
  opacity: 0;
  transition: opacity .3s var(--ease);
}
.overlay.is-open { opacity: 1; }
/* The way out holds, then goes. See the note by the stagger below. */
.overlay:not(.is-open) { transition: opacity .3s cubic-bezier(.4, 0, 1, 1); }
.overlay[hidden] { display: none; }

.close-btn {
  position: fixed;
  top: clamp(1.5rem, 4vh, 2.75rem);
  right: clamp(1.5rem, 5vw, 4rem);
  width: 30px; height: 30px;
  padding: 0; border: 0;
  background: none;
  color: var(--ink);
  cursor: pointer;
  z-index: 51;
  transition: transform .25s ease;
}
.close-btn svg { width: 100%; height: 100%; display: block; }
.close-btn:hover { transform: rotate(90deg); }

.overlay-inner {
  max-width: 66rem;
  margin: 0 auto;
  /* Sized against the viewport HEIGHT, not only its width. The About
     panel should hold on one screen on a desktop, and the only way to
     guarantee that across a 760px laptop and a 1080px monitor is to let
     the vertical rhythm compress with vh. The overlay keeps overflow-y
     auto as a safety valve for genuinely tiny windows. */
  padding: clamp(2.25rem, 6.4vh, 5rem) clamp(1.5rem, 6vw, 3rem) clamp(1.25rem, 4vh, 3.5rem);
}
/* The headline carries the whole panel: heavy, tight, and large enough to
   run to two lines. Everything else stays plain. */
.overlay-name {
  margin: 0 0 clamp(1rem, 3vh, 2.25rem);
  font-weight: 800;
  /* The tallest single element, so it takes a vh ceiling too. */
  font-size: clamp(2rem, min(5.2vw, 7vh), 3.9rem);
  line-height: 1.04;
  letter-spacing: -0.035em;
  text-wrap: balance;
  max-width: 27ch;
}

.overlay-body p {
  margin: 0 0 clamp(.75em, 2.35vh, 1.4em);
  font-size: clamp(1.0625rem, 1rem + .35vw, 1.25rem);
  line-height: 1.55;
  color: var(--ink);
  max-width: 78ch;
}
.overlay-body p:last-child { margin-bottom: 0; }

.overlay-find { margin-top: 1.5em !important; }

.overlay-body a {
  color: inherit;
  text-decoration: underline;
  text-underline-offset: 3px;
  text-decoration-thickness: 1px;
  transition: opacity .2s ease;
}
.overlay-body a:hover { opacity: .55; }


/* The sign-off. Sized in ch so it stays in proportion to the copy above it
   rather than to the viewport - it should read as the hand at the end of a
   letter, not as a banner. */
.overlay-signoff {
  display: block;
  width: clamp(9.75rem, 27vh, 22.5rem);
  height: auto;
  margin: clamp(1.25rem, 3.2vh, 3.25rem) 0 0;
  /* The file is pure black; the page's ink is #0b0b0b and the copy above sits
     at that value. Nudged down slightly so the signature reads as ink on the
     page rather than as the darkest thing on it. */
  opacity: .88;
}

/* The panel composes itself rather than switching on. Each piece rises a
   few pixels into place, in reading order, on the same curve the gallery
   glides with. The delays are the whole effect: without them this is the
   cross-fade it used to be, only slower. */
.overlay-name,
.overlay-body p,
.overlay-signoff {
  opacity: 0;
  transform: translateY(15px);
  transition: opacity .5s var(--ease), transform .5s var(--ease);
}
/* Opacity only. The X owns its transform for the hover rotation. */
.overlay .close-btn {
  opacity: 0;
  transition: opacity .4s var(--ease), transform .25s ease;
}

.overlay.is-open .overlay-name,
.overlay.is-open .overlay-body p { opacity: 1; transform: none; }
/* Not 1: the signature sits at .88 so it reads as ink rather than as the
   darkest thing on the page. */
.overlay.is-open .overlay-signoff { opacity: .88; transform: none; }
.overlay.is-open .close-btn { opacity: 1; }

.overlay.is-open .overlay-name                { transition-delay: .16s; }
.overlay.is-open .overlay-body p:nth-child(1) { transition-delay: .23s; }
.overlay.is-open .overlay-body p:nth-child(2) { transition-delay: .29s; }
.overlay.is-open .overlay-body p:nth-child(3) { transition-delay: .35s; }
.overlay.is-open .overlay-body p:nth-child(4) { transition-delay: .41s; }
.overlay.is-open .close-btn                   { transition-delay: .38s; }
.overlay.is-open .overlay-signoff             { transition-delay: .46s; }

/* Leaving, everything goes at once: a reverse stagger reads as the panel
   hesitating. The curve flips to an ease-in so it holds for a beat and
   then goes, rather than being most of the way gone before the eye has
   caught up. The copy settles back down the 15px it came up. */
.overlay:not(.is-open) .overlay-name,
.overlay:not(.is-open) .overlay-body p,
.overlay:not(.is-open) .overlay-signoff,
.overlay:not(.is-open) .close-btn {
  transition-duration: .24s;
  transition-delay: 0s;
  transition-timing-function: cubic-bezier(.4, 0, 1, 1);
}

@media (max-width: 900px) {
  .overlay-signoff { width: clamp(12rem, 60vw, 18rem); margin-top: 2.5rem; }
}

/* ════════════════════ RESPONSIVE ════════════════════ */

@media (max-width: 900px) {
  /* The rail carried desktop-sized devices: a 368px Mac on a 390px screen,
     which is why the first item could not centre and why crossing four
     projects took several swipes. Sized to the viewport here instead. */
  .mac   { width: min(72vw, 56vh); }
  .phone { width: min(31vw, 27vh); }
  .track { gap: clamp(1.75rem, 8vw, 3rem); }

  .detail {
    /* The device stacks above the panel here rather than sitting beside it,
       so the panel takes everything from just under the device down to the
       bottom inset. It used to be capped at 30vh, which hid roughly half of
       every project's text. --detail-top already accounts for the App Store
       badge when a project has one. */
    top: var(--detail-top, 62%);
    bottom: clamp(1rem, 4vh, 2.25rem);
    height: auto;
    max-height: none;
    padding-block: .25rem;
    left: 50%;
    width: min(24rem, 88vw);
    transform: translateX(-50%) translateY(.75rem);
    text-align: left;
    align-items: stretch;
  }
  /* Top-aligned here rather than centred. Stacked, the panel is read from
     the top down and the title has to be the first thing in it; centring
     short copy pushes the title down the screen. */
  .detail { justify-content: flex-start; }
  body.is-detail .detail { transform: translateX(-50%); }
  .detail-credits { gap: .2rem; }
  /* Generous on a desktop, most of the remaining screen on a phone. */
  .detail { padding-block: 1.25rem; }
  .detail-credits { margin-top: 1.6rem; }
  .detail-links { margin-top: 1.4rem; }

  /* In the text flow here, not hanging off the device - placeDetail
     reparents it into the panel on this layout. */
  .detail-cta {
    position: static;
    transform: none;
    margin: 1.85rem 0 0;
    align-self: flex-start;
  }
  body.is-detail .detail-cta { transform: none; }
}

@media (max-width: 560px) {
  /* The credits used to be hidden here - written when the panel was
     capped at 30vh and genuinely had no room. It runs to the bottom of
     the screen now, and the names carry profile links, so hiding them
     would remove those from every phone. Tightened instead. */
  .detail-credits { gap: .32rem; font-size: 1rem; }
  /* A 20px-tall text link is a poor tap target. Vertical padding on an
     inline element grows the hit area without altering the line box, so
     the list keeps its rhythm. */
  .detail-credits a { padding-block: .35rem; }
}

/* ════════════════════ MOTION PREFERENCES ════════════════════ */

@media (prefers-reduced-motion: reduce) {
  .caret { animation: none; opacity: 0; }
  .intro, .gallery, .head, .info-btn, .back-btn, .detail, .device, .item, .overlay,
  .overlay-name, .overlay-body p, .overlay-signoff, .close-btn {
    transition-duration: 1ms !important;
    transition-delay: 0ms !important;
  }
  .overlay-name, .overlay-body p, .overlay-signoff { transform: none !important; }
  .info-btn:hover, .back-btn:hover, .close-btn:hover { transform: none; }
}

/* Short desktop windows - a half-height browser, or a laptop with a lot of
   chrome. The vh clamps carry the About panel down to roughly a 600px
   viewport; below that they hit their floors and it starts to scroll.
   Trimming here rather than tightening those clamps leaves full-size windows
   untouched, which is where the design actually lives.

   Must sit at the end of the file: it overrides base rules of equal
   specificity, so source order is what makes it win. */
@media (min-width: 901px) and (max-height: 640px) {
  .overlay-inner   { padding-top: 1.75rem; padding-bottom: 1rem; }
  .overlay-name    { margin-bottom: .75rem; }
  .overlay-body p  { margin-bottom: .55em; }
  .overlay-signoff { margin-top: .9rem; width: 9.75rem; }
}

/* A recording wider than the display it sits in. The PMA capture is 2.2:1
   against a 1.55:1 screen, so cover would crop a third of the width and cut
   through the body text. Contained instead, with the bands taking the page's
   own white rather than the black the other recordings sit on. */
.device-video--wide {
  object-fit: contain;
  /* Anchored to the top, not centred. Centring puts a white band under
     the bezel notch, and the notch gradient is black - a black bar on
     white. Top-anchored, the page's own dark nav sits there instead and
     the slack falls to the bottom, where the page is white anyway. */
  object-position: top;
  background: #fff;
}
