/* ═══════════════════════════════════════════════════════════
   mahfuzmaruf.me — LAYOUT & COMPONENTS
   Tokens live in tokens.css and must be loaded first.
   Do not hardcode a colour, size or duration here; if a value is
   needed twice, add it to tokens.css instead.
   ═══════════════════════════════════════════════════════════ */

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

html { -webkit-text-size-adjust: 100%; }

body {
  margin: 0;
  min-height: 100vh;
  background: var(--bg);
  color: var(--ink);
  font-family: var(--font-sans);
  font-size: var(--fs-sm);
  line-height: 1.5;
  -webkit-font-smoothing: antialiased;
}

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

.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;
}

/* ═══ HEADER ═══════════════════════════════════════════════ */

.header {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: var(--s4);
  height: var(--header-h);
  padding-inline: var(--gutter);
  background: var(--bg);
}

/* ═══ SHELL — CONTAINER PAGES ══════════════════════════════
   On pages with the main/rail split the header is PART of the white
   panel: the body becomes the grid, the header takes the panel's top
   row (rounded top corners) and .main its bottom (rounded bottom),
   reading as one card floating on the page colour, while the rail
   runs the full right column. Scoped with :has so design-system and
   404, which have no .container, keep the plain full-width header.
   The tablet and phone queries put the body back to a plain block. */

body:has(> .container) {
  display: grid;
  grid-template-columns: var(--col-main) 1fr;
  grid-template-rows: auto 1fr;
  min-height: 100dvh;
  /* the page-colour frame around the panel */
  padding: var(--s2);
}

body:has(> .container) .header {
  grid-area: 1 / 1;
  background: var(--surface);
  border-radius: var(--radius) var(--radius) 0 0;
  /* inset relative to the panel now, not the viewport edge */
  padding-inline: var(--s5);
}

.brand {
  display: flex;
  align-items: baseline;
  gap: var(--brand-gap);
  font-family: var(--font-nav-title);
  font-size: var(--fs-nav-title);     /* 20 */
  font-style: normal;
  font-weight: var(--fw-nav-title);   /* 600 */
  line-height: var(--lh-nav-title);   /* 26 */
  color: currentColor;
}

/* The two-bar mark beside the name — same ascending-bars idea as the
   hero's spark glyph, bottom-aligned like a chart. Heights are the
   Figma export's exactly — two skewed parallelogram bars, 19×31,
   given as a literal inline SVG rather than redrawn in CSS so the
   path data matches pixel-for-pixel. flex:none keeps it from being
   squeezed by the wordmark next to it. */
.brand-mark {
  display: block;
  flex: none;
  height: var(--brand-mark-h);
  width: auto;
  aspect-ratio: 19 / 31;   /* the SVG's own viewBox */
}

.header-nav {
  display: flex;
  gap: var(--s4);
  font-size: var(--fs-nav);
  font-weight: 500;
}

.header-nav a { color: var(--ink-mute); transition: color .18s ease; }
.header-nav a:hover { color: var(--ink); }

/* ═══ SHELL ════════════════════════════════════════════════
   Main : rail  =  3 : 1   (75% / 25%)                       */

.container {
  /* The body is the real grid on desktop (see the shell above) — the
     container dissolves so .main and .rail become its items. The
     tablet and phone queries give it a display of its own again. */
  display: contents;
}

.main {
  grid-area: 2 / 1;
  display: flex;
  flex-direction: column;
  align-items: center;
  /* Same ceiling as the rail. Without a definite height a panel that
     scrolls internally (see .main-skills) has nothing to measure
     against and grows the page instead. Header and the body's frame
     both live inside 100dvh now. */
  max-height: calc(100dvh - var(--header-h) - 2 * var(--s2));
  min-height: 0;
  /* No side padding: .cards carries its own inset, so the panel's
     edges belong to the panel. Rounded only at the bottom — the
     header above carries the panel's top corners. */
  padding: var(--s4) 0 var(--s3);
  background: var(--surface);
  border-radius: 0 0 var(--radius) var(--radius);
}

.rail {
  /* full right column, alongside header and main both */
  grid-area: 1 / 2 / 3 / 3;
  display: flex;
  flex-direction: column;
  background: var(--bg);
  /* A definite ceiling. Without it `flex: 1` on .messages has nothing
     to measure against, so a growing thread stretches the rail and
     pushes the page instead of scrolling inside it. min-height:0 lets
     the flex children actually shrink to fit. */
  max-height: calc(100dvh - 2 * var(--s2));
  min-height: 0;
  /* Matches the main panel's bottom inset (s3) so "My thinking" lands
     on the same line as the dock. */
  padding-bottom: var(--s3);
  /* Lets .thought-grid respond to the rail's own width rather than
     the viewport's — the rail is ~400px on a 13" laptop and ~600px
     on a large display, which is what actually decides the layout. */
  container-type: inline-size;
}

/* One inset governs the hero, and .thinking repeats it. Everything
   in the rail therefore starts on the same left edge. */
.rail-body {
  flex: 1;
  display: flex;
  flex-direction: column;
  min-height: 0;
  padding: var(--s5) var(--pad-section) 0;
}

/* ═══ CARDS ════════════════════════════════════════════════
   Screen aspect = 1 : √φ. Both cards share one grid track,
   so their captions land on the same baseline.              */

.cards {
  /* The universal card rule, same at every desktop size: each card is
     as big as the SCREEN allows. The track cap is the height budget —
     whatever 100dvh leaves after the panel's fixed chrome, converted
     to a width by the card's aspect — so a taller screen grows the
     cards and a shorter one shrinks them, always exactly filling the
     panel. When the panel is too narrow for two such cards plus the
     standard gutter, the tracks give way and the pair shrinks to the
     width instead, content scaling along. 416×470 (the Figma card)
     is simply what a 876px-tall screen produces. */
  display: grid;
  grid-template-columns: repeat(2, minmax(0, calc((100dvh - var(--card-chrome)) * var(--card-aspect))));
  justify-content: center;
  gap: var(--card-gap);
  width: 100%;
  /* When the pair is width-bound the tracks would otherwise run right
     to the panel edges — keep one standard gutter (the same --card-gap
     that separates the cards) as a minimum inset on both sides. */
  padding-inline: var(--card-gap);
  /* Centred in every aspect: the pair is centred horizontally by the
     grid above, and margin-block auto splits the panel's leftover
     height equally above and below the group, so the cards float in
     the middle between the header and the dock instead of leaving all
     the slack piled under the captions. The dock keeps the panel's
     bottom edge (it is the last flex child; the bottom auto margin
     still pushes it there). */
  margin: auto 0;
}

.card { margin: 0; }

.screen {
  position: relative;
  overflow: hidden;
  display: flex;
  align-items: flex-end;
  aspect-ratio: var(--card-aspect);
  /* The grid track above already carries the screen-size rule (height
     budget capped by panel width), so the screen simply fills it. The
     height guard is repeated here as a belt for layouts that size the
     track differently (e.g. the tablet query's fixed cap). */
  width: min(100%, (100vh  - var(--card-chrome)) * var(--card-aspect));
  width: min(100%, (100dvh - var(--card-chrome)) * var(--card-aspect));
  margin-inline: auto;
  /* the in-card nav's cqi font clamp resolves against this width */
  container-type: inline-size;
  padding: var(--s3);
  background: var(--screen);
  /* A shade softer than the nav bar floating inside it, so the card
     lifts off the panel without competing with its own contents. */
  box-shadow: var(--shadow-card);
  /* Rounded to match the dock's 15px; overflow: hidden clips the
     map tiles to the same corner. */
  border-radius: 15px;
}

/* ── Map ──
   Leaflet fills the screen and sits behind the nav bar. Configure
   coordinates and zoom in js/maps.js, not here. */

.map {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  /* shown while tiles load, and if the network is unavailable */
  background: var(--screen);
  z-index: 0;
}

/* Leaflet ships its own font stack and link colours — bring both
   back in line with the rest of the page. */
.map.leaflet-container {
  font-family: var(--font-sans);
  background: var(--screen);
}

.map .leaflet-control-attribution {
  padding: 2px 6px;
  background: rgba(255, 255, 255, .8);
  font-size: 10px;
  line-height: 1.4;
  color: var(--ink-mute);
  /* clears the floating nav bar, which is inset --s3 from the bottom */
  margin-bottom: 0;
}
.map .leaflet-control-attribution a { color: var(--ink-mute); text-decoration: underline; }

/* ── Country names ──
   Drawn by us, because no raster variant shows countries while
   hiding divisions and cities. Both classes are Leaflet divIcons,
   so they sit at real coordinates and move with the map. */

.map-country,
.map-neighbour {
  display: flex;
  align-items: center;
  justify-content: center;
  font-family: var(--font-sans);
  line-height: 1.2;
  letter-spacing: .08em;
  text-transform: uppercase;
  text-align: center;
  white-space: nowrap;
  pointer-events: none;
  /* the tiles beneath are pale, so a soft halo keeps the text legible
     without needing a box around it */
  text-shadow:
     0 0 3px var(--surface), 0 0 3px var(--surface),
     0 0 6px var(--surface);
}

.map-country {
  font-size: var(--fs-nav);
  font-weight: 700;
  color: var(--ink);
}

.map-neighbour {
  font-size: var(--fs-micro);
  font-weight: 500;
  color: var(--ink-mute);
}

/* ── Place markers ──
   Every place on a card renders a marker. The circular pin styling
   sits on the inner .pin, never on the marker root: Leaflet positions
   markers with an inline transform, so transforming the root would
   fight it and send the marker to the wrong coordinates. */

.place-marker { cursor: pointer; }

.place-marker .pin {
  display: flex;
  align-items: center;
  justify-content: center;
  width: 100%;
  height: 100%;
  background: var(--accent);
  border: 2.5px solid #fff;
  border-radius: 50%;
  box-shadow: var(--shadow-md);
  transition: transform .18s cubic-bezier(.34, 1.4, .64, 1);
}

.place-marker .pin svg { width: 15px; height: 15px; fill: #fff; }

.place-marker:hover .pin,
.place-marker:focus-visible .pin { transform: scale(1.14); }

.place-marker.is-selected .pin {
  transform: scale(1.2);
  box-shadow: 0 2px 9px rgba(11, 27, 58, .5);
}

.place-marker:focus-visible {
  outline: 2px solid var(--accent);
  outline-offset: 3px;
  border-radius: 50%;
}

/* Fallback pulsing dot, for a place with no icon set. */
.pin-dot { background: none !important; border: 0 !important; box-shadow: none !important; }
.pin-dot .core,
.pin-dot .pulse { position: absolute; inset: 0; border-radius: 50%; }
.pin-dot .core {
  inset: 8px;
  background: var(--accent);
  border: 2.5px solid #fff;
  box-shadow: 0 1px 4px rgba(11, 27, 58, .45);
}
.pin-dot .pulse {
  inset: 4px;
  background: var(--accent);
  opacity: .3;
  animation: dot-pulse 2.4s ease-out infinite;
}

@keyframes dot-pulse {
  0%   { transform: scale(.5); opacity: .45; }
  70%  { transform: scale(2.1); opacity: 0; }
  100% { transform: scale(2.1); opacity: 0; }
}

@media (prefers-reduced-motion: reduce) {
  .pin-dot .pulse { animation: none; opacity: .22; }
  .place-marker .pin { transition: none; }
}

/* ── Place label ── */

.map .map-label,
.map .map-label.leaflet-tooltip {
  padding: 3px 8px;
  background: var(--surface);
  border: 0;
  border-radius: 6px;
  box-shadow: 0 1px 4px rgba(11, 27, 58, .22);
  font-family: var(--font-sans);
  font-size: 12px;
  font-weight: 500;
  line-height: 1.3;
  color: var(--ink);
  white-space: nowrap;
}

/* Leaflet draws a callout arrow on tooltips; this label is a plain pill. */
.map .map-label::before { display: none; }

.screen-nav {
  position: relative;
  z-index: 1;
  display: grid;
  grid-template-columns: repeat(3, minmax(0, 1fr));
  align-items: center;
  width: 100%;
  padding: var(--s4) var(--s2);
  background: var(--surface);
  border-radius: var(--radius-sm);
  /* Lifts the white bar off the map tiles beneath it — same shadow as
     hovered cards, so "raised" means one thing across the site. */
  box-shadow: var(--shadow-lift);
}

.screen-nav-item {
  position: relative;
  display: flex;
  align-items: center;
  justify-content: center;
  /* hug the content so the underline matches the label's width
     rather than stretching across the whole grid third */
  justify-self: center;
  gap: 6px;
  /* items are a mix of <a> and <button> — normalise the button */
  padding: 0;
  background: none;
  border: 0;
  font-family: inherit;
  cursor: pointer;
  /* fixed row height: an item with an icon must not make its nav bar
     taller than the icon-less one in the neighbouring card */
  min-height: 14px;
  /* tracks the card width (1cqi = 1% of .screen) so the three labels
     never collide when the height guard shrinks the card */
  font-size: clamp(11px, 4.1cqi, var(--fs-nav));
  font-weight: 500;
  line-height: 1;
  color: var(--ink);
  transition: opacity .18s ease;
}

.screen-nav-item:hover { opacity: .55; }

/* Selected: label, icon (stroke="currentColor") and underline all
   take the accent. The rule sits in the nav bar's existing bottom
   padding, so showing it never changes the bar's height. */
.screen-nav-item.is-active { color: var(--accent); opacity: 1; }

.screen-nav-item::after {
  content: '';
  position: absolute;
  left: 0;
  right: 0;
  bottom: -10px;
  height: 3px;
  background: currentColor;
  border-radius: 2px;
  transform: scaleX(0);
  transform-origin: center;
  opacity: 0;
  transition: transform .42s cubic-bezier(.22, .9, .3, 1),
              opacity   .28s ease;
}

.screen-nav-item.is-active::after {
  transform: scaleX(1);
  opacity: 1;
}

@media (prefers-reduced-motion: reduce) {
  .screen-nav-item::after { transition: none; }
}
.screen-nav-item .icon { width: 14px; height: 14px; flex: none; }

.card-caption {
  margin: 0;
  padding-top: var(--s3);
  text-align: center;
}

/* Captions align to the card's outer grid edge, not its centre: card 1
   (cols 2–3) reads left from column 2, card 2 (cols 4–5) reads right to
   column 5. margin-inline flips the flag bar to the same edge. */
.card:first-child .card-caption { text-align: left; }
.card:first-child .flag { margin-inline: 0 auto; }
.card:last-child .card-caption { text-align: right; }
.card:last-child .flag { margin-inline: auto 0; }

/* ── Flag bar ──
   Colour abstraction, not the actual flag artwork — Bangladesh's is a
   red disc on green, not a split. Segments are flex:1 so each band is
   equal and the strip is centred on the title regardless of how many
   colours a flag has (2 for BD, 3 for DE). */

.flag {
  display: flex;
  width: 132px;
  height: 12px;
  margin: 0 auto var(--s2);
  overflow: hidden;
  border-radius: 2px;
}

.flag i { flex: 1; }

.flag-bd i:nth-child(1) { background: var(--flag-bd-green); }  /* bottle green */
.flag-bd i:nth-child(2) { background: var(--flag-bd-red); }  /* red          */

.flag-de i:nth-child(1) { background: var(--flag-de-black); }
.flag-de i:nth-child(2) { background: var(--flag-de-red); }
.flag-de i:nth-child(3) { background: var(--flag-de-gold); }

.card-title {
  margin: 0;
  font-size: var(--fs-title);
  font-weight: 700;
  line-height: 1.2;
  letter-spacing: -.01em;
}

.card-meta {
  margin: var(--s1) 0 0;
  font-size: var(--fs-body);
  font-weight: 400;
  line-height: 1.2;
}

/* ═══ ICON RAIL ════════════════════════════════════════════
   The launch bar: one white pill holding five icon-only items.
   Markup comes from js/dock.js. The active item wears a tinted chip
   with its label slid open beside the glyph; the rest stay muted and
   reveal their label on hover, so the rail keeps the smallest
   footprint it can while never hiding where you are.
   ═══════════════════════════════════════════════════════════ */

.iconrail {
  flex: none;
  margin-top: var(--s3);
  display: flex;
  align-items: center;
  gap: 2px;
  /* fit-content: the pill is exactly as wide as its items, so it
     grows and shrinks as labels slide open rather than reserving
     room for the widest state. */
  width: fit-content;
  padding: 6px;
  background: var(--surface);
  border: 1px solid var(--rail-border);
  border-radius: var(--radius-rail);
  box-shadow: var(--shadow-lift);
}

/* One colour per item drives both glyph and label: the glyph strokes
   with currentColor and the label inherits it, so a single property
   flips the whole item between states. */
.iconrail-item {
  position: relative;
  display: flex;
  align-items: center;
  gap: 0;
  padding: 8px;
  background: none;
  border: 0;
  border-radius: 10px;
  cursor: pointer;
  color: var(--rail-ink);
  transition: background var(--dur-fast) var(--ease),
              color var(--dur-fast) var(--ease);
}

.iconrail-icon {
  width: 18px;
  height: 18px;
  flex: none;
  display: block;
}

/* The label is always in the DOM — revealing it animates max-width
   from 0, which is the one length that can transition to "as wide as
   the word is". The margin opens with it so the gap does not appear
   before the text does. */
.iconrail-label {
  max-width: 0;
  overflow: hidden;
  margin-left: 0;
  font-size: 13px;
  font-weight: 600;
  line-height: 1.2;
  white-space: nowrap;
  opacity: 0;
  transition: max-width var(--dur-mid) var(--ease),
              margin-left var(--dur-mid) var(--ease),
              opacity var(--dur-fast) var(--ease);
}

/* Idle, hovered: a quiet grey pad and the label slides out. Focus is
   given the same treatment so keyboard users see what pointer users
   see, plus a visible ring. */
.iconrail-item:hover,
.iconrail-item:focus-visible {
  background: var(--rail-hover);
  color: var(--ink);
}

.iconrail-item:focus-visible {
  outline: 2px solid var(--rail-chip-ink);
  outline-offset: 2px;
}

.iconrail-item:hover .iconrail-label,
.iconrail-item:focus-visible .iconrail-label,
.iconrail-item.is-active .iconrail-label {
  /* Generous enough for the longest label; max-width only caps, so
     the chip still sizes to the actual word. */
  max-width: 8rem;
  margin-left: 6px;
  opacity: 1;
}

/* Active: the one blue chip in the rail. It keeps its label open at
   rest, which is what marks the current page. */
.iconrail-item.is-active {
  background: var(--rail-chip);
  color: var(--rail-chip-ink);
}

.iconrail-item.is-active:hover,
.iconrail-item.is-active:focus-visible {
  background: var(--rail-chip);
  color: var(--rail-chip-ink);
}

/* Launcher open: More holds the only chip, so the route's item stands
   down to the idle look until the panel closes. */
.iconrail.is-launching .iconrail-item.is-active:not(.iconrail-more) {
  background: none;
  color: var(--rail-ink);
}

.iconrail.is-launching .iconrail-item.is-active:not(.iconrail-more):not(:hover) .iconrail-label {
  max-width: 0;
  margin-left: 0;
  opacity: 0;
}

@media (prefers-reduced-motion: reduce) {
  .iconrail-item,
  .iconrail-label { transition: none; }
}

/* ═══ LAUNCHER PANEL ═══════════════════════════════════════
   The grid the rail's More item reveals. Markup comes from
   js/dock.js; these are the styles for it.
   ═══════════════════════════════════════════════════════════ */

.dock-wrap { position: relative; display: flex; flex-direction: column; align-items: center; }

.launcher-label {
  font-size: var(--fs-nav);
  font-weight: 500;
  line-height: 1.2;
  color: var(--dock-idle-label);
  white-space: nowrap;
}

/* ── Launcher panel ── */

.launcher {
  position: absolute;
  bottom: calc(100% + var(--s3));
  left: 50%;
  translate: -50% 0;
  z-index: 2;
  padding: var(--s4);
  background: rgba(230, 231, 235, .92);
  backdrop-filter: blur(24px);
  -webkit-backdrop-filter: blur(24px);
  border-radius: var(--radius);
  box-shadow: 0 0 0 .5px var(--hairline), 0 12px 32px rgba(11, 27, 58, .18);
  animation: launcher-in var(--dur-mid) var(--ease-spring) both;
}

.launcher[hidden] { display: none; }

@keyframes launcher-in {
  from { opacity: 0; translate: -50% 8px; scale: .96; }
  to   { opacity: 1; translate: -50% 0;   scale: 1; }
}

/* Row gap is one Fibonacci step above the column gap (34 / 21 = φ),
   so the grid breathes vertically without the columns drifting apart
   — labels sit under their own tile, not between two. */
.launcher-grid {
  display: grid;
  grid-template-columns: repeat(4, 1fr);
  gap: var(--s4) var(--s3);
}

.launcher-item {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: var(--s1);
  width: 68px;
}

/* The launcher's mono tiles are the only tiles left since the dock
   became an icon rail, so they carry their own size. */
.tile-mono {
  display: flex;
  align-items: center;
  justify-content: center;
  width: 40px;
  height: 40px;
  border-radius: 9px;
  background: var(--surface);
  box-shadow: inset 0 0 0 1px var(--hairline), var(--shadow-sm);
  font-size: var(--fs-sm);
  font-weight: 500;
  color: var(--accent);
}

.launcher-item:hover .tile-mono { transform: scale(1.08); }
.tile-mono { transition: transform var(--dur-fast) var(--ease-spring); }

@media (prefers-reduced-motion: reduce) {
  .launcher { animation: none; }
  .tile-mono { transition: none; }
}

/* ═══ RAIL — HERO ══════════════════════════════════════════ */

.hero { padding-bottom: var(--s4); }

/* Display type is capped at the scale value but shrinks with the
   rail once it is too narrow to hold it — 1cqi is 1% of the rail's
   width. At a 20% rail (~290px on a 13" laptop) the 50px headline
   would otherwise wrap mid-phrase or overflow. */
.hero-title {
  margin: 0;
  font-size: var(--fs-hero-fluid);
  font-weight: 700;
  line-height: 1.2;
  letter-spacing: -.02em;
  text-align: center;
}

/* Bar-chart glyph closing the headline. Three equal bars on equal
   gaps, ascending — the first is the sentence's full stop, kept in
   the text colour so it still reads as punctuation. Sized in em so
   it scales with the type; bars grow from the baseline, as a chart
   sits on its axis. */
.spark {
  display: inline-block;
  width: .65em;
  height: .48em;
  margin-left: .09em;
  vertical-align: baseline;
}

.spark svg { width: 100%; height: 100%; display: block; fill: var(--accent); }
.spark .spark-stop { fill: currentColor; }

/* ═══ RAIL — CHAT ══════════════════════════════════════════
   The thread. Markup and behaviour live in js/chat.js; this is its
   appearance. Bubbles stack from the bottom and the area scrolls
   once it outgrows the rail's slack.
   ═══════════════════════════════════════════════════════════ */

.messages {
  flex: 1;
  min-height: 0;              /* lets the flex child actually shrink */
  display: flex;
  flex-direction: column;
  overflow-y: auto;
  overscroll-behavior: contain;   /* don't hand scrolling to the page */
  padding-bottom: var(--s4);
  scrollbar-width: thin;
  scrollbar-color: rgba(0, 0, 0, .18) transparent;
}

.messages::-webkit-scrollbar { width: 6px; }
.messages::-webkit-scrollbar-thumb {
  background: rgba(0, 0, 0, .18);
  border-radius: 3px;
}
.messages::-webkit-scrollbar-track { background: transparent; }

/* Fades the top edge so a scrolled-away message looks deliberately
   cut rather than chopped. Only once there is a thread — while the
   hint is centred there is nothing to cut. */
.messages.has-message {
  mask-image: linear-gradient(to bottom, transparent 0, #000 var(--chat-fade), #000 100%);
  -webkit-mask-image: linear-gradient(to bottom, transparent 0, #000 var(--chat-fade), #000 100%);
}

/* margin-top:auto rather than justify-content:flex-end — with
   flex-end an overflowing column cannot be scrolled back to its
   top in several browsers. This pins the thread to the bottom while
   keeping the scrollback reachable. */
.chat {
  /* the measure --pad-bubble's cqi units resolve against */
  container-type: inline-size;
  margin-top: auto;
  display: flex;
  flex-direction: column;
  align-items: flex-end;
  gap: var(--chat-gap);
  width: 100%;
}

/* ── Bubble ── */

.bubble {
  width: fit-content;
  max-width: var(--bubble-max);
  margin: 0;
  padding: var(--pad-bubble);
  background: var(--chat-bubble);
  color: var(--chat-bubble-ink);
  font-size: var(--fs-sm);
  font-weight: 400;
  line-height: var(--lh-snug);
  border-radius: var(--radius-bubble);
  animation: bubble-in var(--dur-mid) var(--ease-spring) both;
}

@keyframes bubble-in {
  from { opacity: 0; transform: translateY(8px) scale(.96); }
  to   { opacity: 1; transform: none; }
}

/* Asked for a second time. The bubble already in the thread is the
   answer, so it is flashed instead of being written again — see
   Chat.say() in js/chat.js. The class is removed on animationend,
   which is why this is an animation and not a transition.

   Deliberately a slow swell: the ramp takes 40% of the run and the
   fade the last 40%, both on ease-in-out, so neither end has a
   corner. An --ease-out curve over the whole thing put most of the
   colour change in the first fraction of a second, which read as a
   snap rather than a highlight. The scale is a lift, not a pop — it
   peaks under the tint's peak and is back to rest before the colour
   starts leaving. */
.bubble.is-recalled {
  animation: bubble-recall var(--dur-recall) ease-in-out both;
}

@keyframes bubble-recall {
  0%   { background: var(--chat-bubble); color: var(--chat-bubble-ink); transform: none; }
  30%  { transform: scale(1.012); }
  40%,
  60%  { background: var(--chat-bubble-recall); color: var(--chat-bubble-recall-ink); }
  55%  { transform: none; }
  100% { background: var(--chat-bubble); color: var(--chat-bubble-ink); transform: none; }
}

@keyframes bubble-recall-still {
  0%   { background: var(--chat-bubble); color: var(--chat-bubble-ink); }
  40%,
  60%  { background: var(--chat-bubble-recall); color: var(--chat-bubble-recall-ink); }
  100% { background: var(--chat-bubble); color: var(--chat-bubble-ink); }
}

/* ── Typing indicator ── */

.typing {
  display: flex;
  align-items: center;
  gap: 5px;
  padding: var(--pad-bubble);
  background: var(--chat-bubble);
  border-radius: var(--radius-bubble);
  animation: bubble-in var(--dur-fast) var(--ease-spring) both;
}

.typing span {
  width: 7px;
  height: 7px;
  /* the bubble ink, not white — white dots vanish on grey */
  background: var(--chat-bubble-ink);
  border-radius: 50%;
  animation: typing 1.1s var(--ease) infinite;
}

.typing span:nth-child(2) { animation-delay: .16s; }
.typing span:nth-child(3) { animation-delay: .32s; }

@keyframes typing {
  0%, 65%, 100% { opacity: .45; transform: translateY(0); }
  32%           { opacity: 1;   transform: translateY(-4px); }
}

/* ── Hint (empty state) ── */

/* Empty state: the hint is centred in the space between the hero and
   "My thinking" rather than tucked under the hero — it is the only
   thing in the panel at rest, so it reads as the panel's content
   instead of a caption on the headline. margin-top:auto must be
   overridden here, otherwise it pins the hint to the bottom and
   justify-content has nothing left to centre. */
/* padding-bottom exists to keep the newest bubble off the bottom
   edge; with nothing but the hint it would just push the centre
   34px high, so it comes off in the empty state. */
.messages:not(.has-message) { justify-content: center; padding-bottom: 0; }
.messages:not(.has-message) .chat { margin-top: 0; align-items: center; }

.hint {
  margin: 0;
  font-size: var(--fs-hint);   /* 15 */
  font-weight: 400;
  line-height: 1.6;
  text-align: center;
  color: var(--ink-mute);
  animation: bubble-in var(--dur-mid) var(--ease-spring) both;
}

.hint-dot {
  display: inline-block;
  width: .58em;
  height: .58em;
  margin: 0 .12em;
  background: var(--accent);
  border-radius: 50%;
  vertical-align: baseline;
}

@media (prefers-reduced-motion: reduce) {
  .bubble, .hint, .typing { animation: none; }
  .typing span { animation: none; opacity: .7; }
  /* The recall flash is the only feedback a repeat click gets, so it
     survives here — stripped of the scale nudge, colour only. */
  .bubble.is-recalled {
    animation: bubble-recall-still var(--dur-recall) var(--ease-out) both;
  }
}

/* ═══ RAIL — THINKING ══════════════════════════════════════ */

.thinking {
  flex: none;
  /* No bottom padding of its own: the rail's --s3 inset is the one
     standard bottom margin, so the dots row lands on the same line as
     the dock across the panel (both 21px off the bottom edge). */
  padding: var(--s3) var(--pad-section) 0;
  border-top: 1px solid var(--hairline);
}

.thinking-head {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: var(--s3);
  margin-bottom: var(--s2);
}

.thinking-title {
  margin: 0;
  font-size: var(--fs-sm);
  font-weight: 500;
  line-height: 1.2;
}

/* One post at a time on a snap track: js/thinking.js advances it every
   5s and builds the dots. Without JS it stays a plain horizontal
   scroller, so every post is still reachable. */
.thought-grid {
  display: flex;
  gap: var(--s2);
  overflow-x: auto;
  scroll-snap-type: x mandatory;
  scroll-behavior: smooth;
  overscroll-behavior-inline: contain;
  /* the track is driven by the dots, so its own bar is noise */
  scrollbar-width: none;
}

.thought-grid::-webkit-scrollbar { display: none; }

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

.thought-grid > .thought {
  flex: 0 0 100%;
  scroll-snap-align: center;
}

/* ── Carousel pagination ──
   One dot per post, the current one filled with the accent. Sits
   under the track, centred, where "Check All" used to be. */
.thought-dots {
  display: flex;
  justify-content: center;
  gap: var(--s1);
  margin-top: var(--s2);
}

.thought-dot {
  width: 7px;
  height: 7px;
  padding: 0;
  border: 0;
  border-radius: 50%;
  background: var(--dock-idle-icon);
  cursor: pointer;
  transition: width .22s ease, border-radius .22s ease, background .22s ease;
}

.thought-dot.is-active {
  width: 18px;
  border-radius: 4px;
  background: var(--accent);
}

.thought {
  position: relative;
  display: flex;
  align-items: center;
  min-height: 52px;
  padding: var(--s2) var(--s3) var(--s2) calc(10px + var(--s3));
  background: var(--surface);
  border-radius: var(--radius-sm);
  overflow: hidden;
  transition: transform .18s ease, box-shadow .18s ease;
}

.thought::before {
  content: '';
  position: absolute;
  inset: 0 auto 0 0;
  width: 10px;
  background: var(--accent);
}

.thought:hover {
  transform: translateY(-2px);
  box-shadow: var(--shadow-lift);
}

.thought p {
  margin: 0;
  font-size: var(--fs-body-sm);
  font-weight: 400;
  line-height: 1.45;
  text-align: center;
}

/* ═══ PAGE — SKILLS ════════════════════════════════════════
   skills.html. One flat grid of tools, grouped by discipline.
   Every group holds the same number of items, so the columns line
   up without any per-card sizing.
   ═══════════════════════════════════════════════════════════ */

/* ═══ LONG PAGES ═══════════════════════════════════════════
   A panel whose content outgrows one screen. Shared by skills and
   projects; any future list page should use these rather than
   inventing its own scroller.
   ═══════════════════════════════════════════════════════════ */

.main-scroll,
.main-skills {
  align-items: stretch;
  justify-content: flex-start;
}

/* The sheet scrolls, not the panel — the dock is a sibling of this
   element, so it stays pinned to the bottom of the card instead of
   scrolling away with the content. */
.sheet,
.skills {
  flex: 1;
  min-height: 0;
  overflow-y: auto;
  overscroll-behavior: contain;
  width: 100%;
  max-width: 1000px;
  margin: 0 auto;
  padding-block: var(--s2) var(--s4);
  scrollbar-width: thin;
  scrollbar-color: rgba(0, 0, 0, .18) transparent;
}

/* never squeezed by the scrolling sheet above it */
#dock-root { flex: none; }

.set { margin-bottom: var(--s5); }

/* Groups a run of cards without competing with their names, which
   are what you actually scan. */
.section-label {
  margin: 0 0 var(--s3);
  padding-bottom: var(--s1);
  border-bottom: 1px solid var(--hairline);
  font-size: var(--fs-micro);
  font-weight: 500;
  letter-spacing: .1em;
  text-transform: uppercase;
  color: var(--ink-mute);
}

/* ── Skills — chip cards ──
   Replaced the flat column list (2026-07-26): each tool is now a
   bordered card, its sub-skills laid out as chips rather than a bullet
   list. .cat/.cat-label repeat .set/.section-label's job (grouping a
   run of cards under a label) but are kept separate rather than
   reusing those classes — .set/.section-label are shared with
   projects.html's Power BI/Web Apps groups, which are not part of
   this redesign and must not shift. */

.cat + .cat { margin-top: 26px; }

.cat-label {
  margin: 0 0 14px;
  font-size: 11px;
  font-weight: 700;
  letter-spacing: .14em;
  line-height: 1.2;
  text-transform: uppercase;
  color: var(--chipcard-label);
}

.cat-grid {
  display: grid;
  grid-template-columns: repeat(3, 1fr);
  gap: 12px;
}

/* Structure only: a hairline box, no shadow and no fill beyond the
   page's white, so the chips inside are the only tone on the card. */
.tool {
  padding: 16px 18px;
  background: var(--surface);
  border: 1px solid var(--chipcard-border);
  border-radius: 14px;
}

.tool-name {
  margin: 0 0 11px;
  font-size: 15px;
  font-weight: 600;
  line-height: 1.25;
  color: var(--chipcard-name);
}

.chips {
  display: flex;
  flex-wrap: wrap;
  gap: 7px;
  margin: 0;
  padding: 0;
  list-style: none;
}

.chip {
  padding: 5px 11px;
  background: var(--chip-bg);
  border-radius: 8px;
  font-size: 12.5px;
  font-weight: 500;
  line-height: 1.35;
  color: var(--chip-ink);
}

/* ═══ PAGE — PROJECTS ══════════════════════════════════════ */

.work-grid {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(230px, 1fr));
  gap: var(--s4);
}

.work {
  display: flex;
  flex-direction: column;
  transition: transform var(--dur-fast) var(--ease);
}

.work:hover { transform: translateY(-3px); }

/* √φ, the same ratio as the map cards and the project screenshot —
   one rectangle shape across the whole site. */
.work-thumb {
  display: flex;
  align-items: center;
  justify-content: center;
  aspect-ratio: var(--phi-half) / 1;
  margin-bottom: var(--s2);
  padding: var(--s3);
  background: var(--screen);
  border-radius: var(--radius-sm);
  color: var(--accent);
  overflow: hidden;
  transition: background var(--dur-fast) var(--ease);
}

.work:hover .work-thumb { background: #eef0f3; }

.work-thumb svg { width: 44%; height: 44%; }

/* A bare <img> is a screenshot: fill the thumb edge to edge. */
.work-thumb img { width: 100%; height: 100%; object-fit: cover; border-radius: inherit; }

/* .work-icon is an illustration: sit it centred at glyph size on the
   grey, the same way the SVG placeholders did. Cropping a square
   illustration to a √φ box would cut its edges off. */
.work-thumb .work-icon {
  width: 46%;
  height: auto;
  object-fit: contain;
  border-radius: 0;
}

/* Wordmark stand-ins until real artwork exists. */
.work-mark {
  font-size: var(--fs-body);
  font-weight: 700;
  line-height: var(--lh-tight);
  text-align: center;
}

.work-mark-aw { color: #d93a2b; }
.work-mark-cs { color: var(--accent); }

.work-name {
  margin: 0;
  font-size: var(--fs-sm);
  font-weight: 500;
  line-height: var(--lh-tight);
  color: var(--ink);
}

.work-meta {
  margin: 5px 0 0;
  font-size: var(--fs-nav);
  font-weight: 400;
  line-height: var(--lh-snug);
  color: var(--ink-mute);
}

/* ═══ PAGE — STUDIES ═══════════════════════════════════════
   studies.html. Shares every token with the homepage: same type
   scale, same 4:1 shell, same rail. Only the panel contents differ.
   ═══════════════════════════════════════════════════════════ */

.main-prose { justify-content: center; }

/* One measure and one centre line for both paragraphs. The Figma had
   the lead 770px and the body 968px on different left edges, which
   is what made them look misaligned. */
.prose {
  width: 100%;
  max-width: 62ch;
  margin: auto;
}

.prose-lead {
  margin: 0;
  font-size: var(--fs-lead);
  font-weight: 500;
  line-height: 1.65;
}

/* Rule under the lead. 107px in the mock; φ⁻¹ of that lands on the
   Fibonacci 55+13, so it is built from the spacing scale. */
.prose-rule {
  display: block;
  width: calc(var(--s5) + var(--s5));
  height: 3px;
  margin: var(--s3) 0 var(--s4) var(--s2);
  background: var(--accent);
  border-radius: 2px;
}

.prose-body {
  margin: 0;
  font-size: var(--fs-title);
  font-weight: 400;
  line-height: 1.9;
}

/* Inline emphasis. Kept as a colour shift only — no weight change,
   so the line's rhythm is undisturbed. */
.hl { color: var(--accent); }

a.hl { text-decoration: none; }
a.hl:hover { text-decoration: underline; text-underline-offset: 4px; }

/* ── Rail sub-heading ── */

.hero-sub {
  margin: var(--s1) 0 0;
  font-size: var(--fs-lead-fluid);
  font-weight: 400;
  line-height: 1.3;
  text-align: center;
}

/* ── Rail project card ── */

/* Takes the rail's slack so the card sits between the hero and the
   thinking block, the way .messages does on the homepage. */
/* A bubble that happens to hold media. Sits in .chat like any
   message, so its position, alignment and gap come from the thread
   — nothing here restates them. */
.project {
  display: flex;
  flex-direction: column;
  width: var(--bubble-max);
  padding: var(--pad-bubble);
  background: var(--chat-bubble);
  border-radius: var(--radius-bubble);
}

/* Bubble prose, so it takes the bubble's ink. The title below stays
   full --ink: it is a heading inside the bubble, not more message. */
.project-intro {
  flex: none;
  margin: 0;
  font-size: var(--fs-sm);
  font-weight: 400;
  line-height: var(--lh-snug);
  color: var(--chat-bubble-ink);
}

.project-title {
  flex: none;
  margin: var(--s1) 0 var(--s2);
  font-size: var(--fs-sm);
  font-weight: 500;
  line-height: 1.4;
}

/* Grows into the rail's spare height and gives it back when short.
   The √φ ratio is a ceiling rather than a fixed size — at a 20% rail
   the card would otherwise push the page into a scrollbar. */
/* A real <img>, not a CSS background: it carries alt text, gets
   width/height for layout stability, and can lazy-load. Sits on the
   bubble's standard inset like the copy above it.
   flex-shrink lets it give ground on a short viewport but never past
   the floor — with min-height:0 it collapsed to nothing. */
.project-shot {
  display: block;
  width: 100%;
  height: auto;
  /* never shrinks: the slot scrolls instead, so the screenshot keeps
     its shape at every viewport */
  flex: none;
  aspect-ratio: var(--phi-half) / 1;
  object-fit: cover;
  object-position: top center;
  background: #fff;
  border-radius: var(--radius-xs);
  box-shadow: var(--shadow-sm);
}

.project-cta {
  flex: none;
  display: block;
  margin-top: var(--s2);
  padding: var(--s1) var(--s2);
  background: var(--accent);
  border-radius: var(--radius-sm);
  font-size: var(--fs-nav);
  font-weight: 500;
  line-height: 1.6;
  color: #fff;
  text-align: center;
  transition: filter .18s ease, transform .18s ease;
}

.project-cta:hover { filter: brightness(1.08); transform: translateY(-1px); }

/* ═══ MOBILE COMPONENTS ════════════════════════════════════
   These only appear at the phone breakpoint. They are built into
   the DOM by js/mobile.js on every page but stay hidden until the
   ≤720 query below switches them on, so desktop is untouched.    */

.stories-fab,
.cards-dots,
.deck-hint,
.navbar-toggle { display: none; }


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

/* ── Tablet: one column, rail drops below the main panel ── */
@media (max-width: 1180px) {
  /* Tablet step-down: hero and lead each drop one display step. */
  :root { --fs-hero: var(--fs-display-xl); --fs-lead: var(--fs-display-lg); }
  /* The desktop shell dissolves: the body is a plain block again, the
     header goes back to a full-width bar on the page colour, and the
     container takes over as a one-column grid. */
  body:has(> .container) { display: block; padding: 0; }
  body:has(> .container) .header {
    background: var(--bg);
    border-radius: 0;
    padding-inline: var(--gutter);
  }
  /* One column and no viewport-height stretch: the rail sits below, so
     the page scrolls regardless — stretching the panel would only pin
     the dock a screen-height under the cards, leaving a dead void. */
  .container { display: grid; grid-template-columns: 1fr; min-height: 0; padding: 0 var(--s2) var(--s2); }
  .main { grid-area: auto; max-height: none; padding-bottom: var(--s6); border-radius: var(--radius); }
  .rail { grid-area: auto; max-height: none; }
  /* Rail now sits below, so the page scrolls regardless — the
     viewport-height budget is meaningless here and would only shrink
     the cards pointlessly. Tracks cap at the reference card width
     instead, and the screen fills its track. */
  .cards { grid-template-columns: repeat(2, minmax(0, var(--card-w))); }
  .screen { width: 100%; }
  .rail-body { padding-top: var(--s4); }
  /* Three tool cards can no longer hold a readable line at this width. */
  .cat-grid { grid-template-columns: repeat(2, 1fr); }
}

/* ── Phone ─────────────────────────────────────────────────
   One column, top to bottom: header, hero, a swipe deck of map
   cards, the caption, the page dots. The dock and the chat button
   float over it as one fixed row, and the rail's story thread
   becomes the sheet that chat button opens.

   Everything here is specified in --phone-* tokens: this layout was
   drawn against a 390pt frame in round px rather than derived from
   the φ scale the desktop runs on.                              ── */
@media (max-width: 720px) {
  :root {
    /* One swap for the whole layout. The phone runs Inter, a
       near-black ink, a deeper blue and a cooler page grey — every
       component rule below goes on reading the same semantic names it
       reads on desktop. */
    --font-sans: var(--font-ui);
    --bg:        var(--phone-bg);
    --ink:       var(--phone-ink);
    --ink-mute:  var(--phone-meta);
    --accent:    var(--rail-chip-ink);
    --gutter:    var(--phone-gutter);
    --radius:    var(--radius-card);

    /* The country name keeps the desktop's 24; only the lead drops. */
    --fs-lead:  var(--fs-display-lg);
    --fs-title: var(--fs-display-md);
    --fs-body:  var(--fs-body-md);
  }

  /* The desktop shell dissolves here too (the tablet query already
     does this from 1180 down; repeated so the phone rules stand alone
     if the tablet block ever changes). */
  body:has(> .container) { display: block; padding: 0; }

  /* ── Navbar ──
     A floating card that expands downward into a menu. It is one row —
     brand, then the toggle — with .header-nav wrapped onto a second
     row that is collapsed to nothing until the toggle opens it.

     The wrap is what makes this work without a wrapper element: the
     three children carry `order`, and the nav's 100% flex-basis forces
     the line break. js/mobile.js inserts the toggle; everything else is
     the same markup the desktop header uses.

     Both selectors are listed so this beats the tablet block's own
     `body:has(…) .header` rule on order. */
  body:has(> .container) .header,
  .header {
    position: sticky;
    top: var(--phone-bar-gap);
    z-index: 50;
    flex-wrap: wrap;
    align-items: center;
    height: auto;
    gap: 0;
    margin: var(--phone-bar-gap) var(--gutter) 0;
    padding: var(--phone-bar-pad-y) var(--phone-bar-pad-x);
    background: var(--surface);
    border: 1px solid var(--chipcard-border);
    border-radius: var(--radius-navbar);
  }

  /* The wordmark drops Cal Sans here and runs with the rest of the
     page: at 15px the display face's quirks read as a wobble rather
     than as character. */
  .brand {
    order: 1;
    gap: var(--phone-brand-gap);
    font-family: var(--font-sans);
    font-size: var(--fs-brand-phone);   /* 15 */
    font-weight: var(--fw-bold);
    line-height: 1.2;
    letter-spacing: -.02em;
  }

  .brand-mark { height: var(--brand-mark-phone); }   /* 14 */

  /* ── The toggle ──
     Three rules in a column flex, which is also what lets two of them
     rotate into an X without disturbing the third: `translate` and
     `rotate` never touch layout, so the gap stays put underneath. */
  .navbar-toggle {
    order: 2;
    display: flex;
    flex-direction: column;
    align-items: center;
    justify-content: center;
    gap: var(--phone-burger-gap);
    width: var(--phone-burger);
    height: var(--phone-burger);
    padding: 0;
    background: var(--chip-bg);
    border: 0;
    border-radius: var(--radius-burger);
    cursor: pointer;
  }

  .navbar-toggle span {
    width: var(--phone-burger-bar);
    height: var(--phone-burger-rule);
    background: var(--ink);
    border-radius: var(--phone-burger-rule);
    transition: translate var(--dur-menu) var(--ease-out),
                rotate    var(--dur-menu) var(--ease-out),
                opacity   var(--dur-fast) var(--ease);
  }

  /* 6px is the rules' centre-to-centre spacing (2 tall + 4 apart), so
     the outer two land exactly on the middle one before rotating. */
  .header.is-open .navbar-toggle span:nth-child(1) { translate: 0 6px;  rotate:  45deg; }
  .header.is-open .navbar-toggle span:nth-child(2) { opacity: 0; }
  .header.is-open .navbar-toggle span:nth-child(3) { translate: 0 -6px; rotate: -45deg; }

  /* ── The menu ──
     Negative side margins pull it back out of the bar's 18px inset so
     the rows and the divider run the bar's full width; its own 8px
     container inset then sets them off the edge.

     Everything that opens the box has to be INSIDE it — padding and the
     divider's margin are clipped by `max-height: 0`, a margin on the
     nav itself would not be, and would hold the bar 13px taller while
     closed. */
  .header-nav {
    order: 3;
    /* Wider than the line, which is what forces the wrap, and the
       negative margins then sit it flush to the bar's edges. A plain
       `flex-basis: 100%` would wrap but stay 100% wide — negative
       margins shift a flex item, they do not grow it. */
    flex: 0 0 calc(100% + 2 * var(--phone-bar-pad-x));
    display: block;
    max-height: 0;
    overflow: hidden;
    opacity: 0;
    margin-inline: calc(-1 * var(--phone-bar-pad-x));
    /* Sides only. Padding is a floor that `max-height: 0` cannot
       compress, so a bottom inset here would hold the closed bar 8px
       taller — the ::after below is that inset instead, as a child the
       overflow can clip. */
    padding-inline: var(--phone-menu-pad);
    font-size: var(--fs-row);   /* 15 */
    font-weight: 500;
    transition: max-height var(--dur-menu) var(--ease-out),
                opacity    var(--dur-menu) var(--ease);
  }

  /* A ceiling, not a height — max-height cannot animate from `auto`,
     so it has to be a number the content will never reach. */
  .header.is-open .header-nav { max-height: 60vh; opacity: 1; }

  .header-nav::before {
    content: '';
    display: block;
    height: 1px;
    margin: var(--phone-bar-pad-y) calc(-1 * var(--phone-menu-pad)) var(--phone-menu-pad);
    background: var(--phone-divider);
  }

  /* The menu's bottom inset — see the padding note above. */
  .header-nav::after {
    content: '';
    display: block;
    height: var(--phone-menu-pad);
  }

  .header-nav a {
    display: flex;
    align-items: center;
    justify-content: space-between;
    padding: var(--phone-row-pad);
    border-radius: var(--radius-row);
    color: var(--phone-row-ink);
  }

  /* Chevron drawn from two borders rather than shipped as a glyph —
     it is two lines, and this way it takes its colour from the row's
     state without a second asset. */
  .header-nav a::after {
    content: '';
    width: 7px;
    height: 7px;
    border-right:  2px solid var(--phone-chevron);
    border-bottom: 2px solid var(--phone-chevron);
    border-radius: 1px;
    rotate: -45deg;
    translate: -2px 0;
  }

  /* The row for wherever you are. Same chip as the icon rail and the
     card's tab bar, so "you are here" looks like one thing site-wide. */
  .header-nav a.is-active {
    background: var(--rail-chip);
    color: var(--rail-chip-ink);
  }
  .header-nav a.is-active::after { border-color: var(--rail-chip-ink); }

  @media (prefers-reduced-motion: reduce) {
    .header-nav,
    .navbar-toggle span { transition: none; }
  }

  /* ── The column ──
     .rail and .rail-body collapse to `contents` so the hero and the
     story thread become direct children of the column and can be
     ordered independently of the source markup. */
  .container {
    display: flex;
    flex-direction: column;
    min-height: 0;
    /* The dock is fixed and the page scrolls behind it, so the column
       needs a runway or its last block ends up underneath. */
    padding: 0 0 var(--phone-dock-space);
  }
  .rail,
  .rail-body { display: contents; }

  /* Hero sits directly under the header — the rail's s5 top inset
     reads as a hole at this width. */
  .hero {
    order: 1;
    padding: var(--s1) var(--gutter) var(--s3);
    text-align: center;
  }

  /* Flat 30px rather than the rail's fluid cap: on a phone the
     headline has one width to fit, so there is nothing to interpolate
     between. */
  .hero-title {
    font-size: var(--fs-hero-phone);   /* 30 */
    font-weight: 800;
    line-height: 1.08;
    letter-spacing: -.03em;
  }
  .hero-sub { text-align: center; }

  .main {
    order: 2;
    max-height: none;
    min-height: 0;
    padding: 0 var(--gutter);
    background: none;
    border-radius: 0;
  }

  /* "My thinking" is desktop-only — hidden here to keep the single
     column short (hero → card → dock). */
  .thinking { display: none; }

  /* ── The rail's pill on a phone ──
     It is already the compact form, so it needs almost nothing: only
     the hover-reveal goes, since there is no hover on touch and a tap
     would leave a label stuck open. The active chip stays — it is what
     marks the current section.

     Labels are held closed with max-width rather than `display: none`,
     which would kill the animation the active chip depends on. */
  .iconrail-item:hover .iconrail-label,
  .iconrail-item:focus-visible .iconrail-label {
    max-width: 0;
    margin-left: 0;
    opacity: 0;
  }
  .iconrail-item.is-active .iconrail-label {
    max-width: 8rem;
    margin-left: 6px;
    opacity: 1;
  }

  /* Long-form pages (Study/Skills/Projects) scroll with the page
     rather than inside their own panel. */
  .main-scroll,
  .main-skills,
  .main-prose {
    max-height: none;
    overflow: visible;
  }
  .skills { overflow: visible; }

  /* ── Content pages, organised for the phone ──
     The desktop auto-fill grid collapses to one very wide column on a
     phone, so a single project thumbnail fills the screen and the
     skill list turns into one long scroll. Force two columns instead:
     the data stays scannable and the page is roughly half as tall. */
  .work-grid {
    grid-template-columns: 1fr 1fr;
    gap: var(--s3);
  }
  /* One tool card per row — two would be too narrow for its chips. */
  .cat-grid { grid-template-columns: 1fr; }
  /* Sections sit closer together — s5 was tuned for the tall desktop
     panel and reads as a gap on a phone. */
  .set { margin-bottom: var(--s4); }
  /* Smaller thumbnails need a lighter inset than the desktop s3. */
  .work-thumb { padding: var(--s2); margin-bottom: var(--s1); }

  /* ── Card deck: horizontal, snapping, one card per view ── */
  .cards {
    display: flex;
    grid-template-columns: none;
    /* Fill the main's content box (already inset by one gutter); each
       card is a full-width slide within it. justify-content and
       padding-inline must both be reset from the desktop rule:
       centring a scrollable flex row puts the first slide at a
       negative offset no scroll position can reach, and the desktop
       side inset would shrink every slide. */
    justify-content: flex-start;
    padding-inline: 0;
    width: 100%;
    max-width: none;
    margin: 0;
    gap: var(--gutter);
    overflow-x: auto;
    scroll-snap-type: x mandatory;
    -webkit-overflow-scrolling: touch;
    scrollbar-width: none;
  }
  .cards::-webkit-scrollbar { display: none; }
  .card {
    flex: 0 0 100%;
    scroll-snap-align: center;
  }

  /* One flat 300px card. The desktop sizes the screen from whatever
     height the fold has left; here the page scrolls behind a fixed
     dock, so there is no fold to fit and the card can simply be the
     height it was drawn at. The gradient shows through until the map
     tiles land, and around them if the network is down. */
  .screen {
    display: block;
    width: 100%;
    height: var(--phone-card-h);
    aspect-ratio: auto;
    margin-inline: 0;
    padding: 0;
    background: linear-gradient(180deg,
                var(--phone-screen-top), var(--phone-screen-bottom));
    border: 1px solid var(--rail-border);
    border-radius: var(--radius-card);
    box-shadow: none;
  }

  /* The tab bar is pinned inside the card's bottom corners rather than
     sitting in the flow, so the map runs the full height behind it. */
  .screen-nav {
    position: absolute;
    left: var(--phone-card-inset);
    right: var(--phone-card-inset);
    bottom: var(--phone-card-inset);
    width: auto;
    padding: var(--phone-seg-pad);
    border-radius: var(--radius-seg);
    box-shadow: var(--shadow-card);
  }

  /* Each segment is a full third that fills with the chip when
     active — the same chip the icon rail uses, so "you are here" looks
     the same wherever it appears. */
  .screen-nav-item {
    justify-self: stretch;
    gap: 6px;
    min-height: 0;
    padding: var(--s1) 0;
    border-radius: 10px;
    font-size: var(--fs-seg);   /* 12 */
    font-weight: 600;
    color: var(--rail-ink);
    transition: background var(--dur-fast) var(--ease),
                color var(--dur-fast) var(--ease);
  }
  .screen-nav-item:hover { opacity: 1; }
  .screen-nav-item.is-active {
    background: var(--rail-chip);
    color: var(--rail-chip-ink);
  }
  /* The desktop marks the current view with a rule under the label;
     the chip already says it here, so the rule would only double it. */
  .screen-nav-item::after { display: none; }
  .screen-nav-item .icon { width: 15px; height: 15px; }

  /* Bigger pins for a fingertip. --phone-pin is also read by
     js/maps.js, which sets Leaflet's own icon box. */
  .place-marker .pin { border-width: 2px; }
  .place-marker .pin svg { width: 15px; height: 15px; }

  /* The map credit would sit under the pinned tab bar; lift it clear.
     It stays visible — OSM and CARTO both require it. */
  .map .leaflet-bottom { bottom: var(--phone-attrib-lift); }

  /* ── Under the card ── */
  .card-caption,
  .card:first-child .card-caption,
  .card:last-child .card-caption {
    padding-top: var(--s3);
    text-align: left;
  }
  .card:first-child .flag,
  .card:last-child .flag { margin-inline: 0 auto; }

  .flag {
    width: var(--phone-flag-w);    /* 130 */
    height: var(--phone-flag-h);   /*   6 */
    margin-bottom: var(--s2);
    border-radius: var(--radius-xs);
  }

  .card-title { font-size: var(--fs-title); }   /* 24 / 700 */
  .card-meta {
    font-size: var(--fs-body-sm);   /* 14 */
    color: var(--ink-mute);
  }

  /* The rail's resting hint, restated in the flow — on a phone the
     thread lives in a closed sheet, so the instruction that opens it
     has to be on the page. Built by js/mobile.js. */
  .deck-hint {
    display: block;
    margin: var(--s3) 0 0;
    font-size: var(--fs-caption-phone);   /* 13 */
    line-height: 1.4;
    text-align: center;
    color: var(--rail-ink);
  }
  .deck-hint .hint-dot {
    width: var(--phone-hint-dot);
    height: var(--phone-hint-dot);
    margin: 0 .2em;
  }

  /* With the popup open this line is word-for-word the bubble sitting
     on top of it, so it stands down rather than showing through. */
  .deck-hint { transition: opacity var(--dur-fast) var(--ease); }
  html.stories-open .deck-hint { opacity: 0; }

  /* Pagination under the deck — a dot per card, the current one a
     pill. */
  .cards-dots {
    display: flex;
    justify-content: center;
    gap: var(--s1);
    margin-top: var(--s2);
  }
  .cards-dot {
    width: 6px;
    height: 6px;
    padding: 0;
    border: 0;
    border-radius: 50%;
    background: var(--dock-idle-icon);
    cursor: pointer;
    transition: width .22s ease, border-radius .22s ease, background .22s ease;
  }
  .cards-dot.is-active {
    width: 22px;
    border-radius: 3px;
    background: var(--accent);
  }

  /* ── Dock: the pill and the chat button, one fixed row ──
     js/mobile.js moves the chat button into .dock-wrap on load so the
     two travel together and can never overlap — as two separately
     fixed elements they collided on narrow screens. */
  .dock-wrap {
    position: fixed;
    left: 50%;
    bottom: var(--phone-dock-bottom);
    translate: -50% 0;
    z-index: 60;
    flex-direction: row;
    align-items: center;
    gap: var(--phone-dock-gap);
  }

  .iconrail {
    margin-top: 0;
    padding: var(--phone-seg-pad);
    border-radius: var(--radius-dock);
    transition: opacity var(--dur-fast) var(--ease);
  }

  /* Four columns of launcher tiles plus the desktop's s4 inset is
     wider than a phone — both come in a step, and the panel is capped
     at the viewport less its gutters. */
  .launcher {
    max-width: calc(100vw - 2 * var(--gutter));
    padding: var(--s3);
  }
  .launcher-grid { gap: var(--s3) var(--s2); }
  .launcher-item { width: 56px; }

  /* ── Chat button ──
     A rounded square rather than a circle, so it reads as a sibling of
     the pill beside it rather than as a floating action button. */
  .stories-fab {
    position: static;
    flex: none;
    display: flex;
    align-items: center;
    justify-content: center;
    width: var(--phone-chat-btn);
    height: var(--phone-chat-btn);
    border: 0;
    border-radius: var(--radius-dock);
    background: var(--accent);
    color: #fff;
    cursor: pointer;
    box-shadow: var(--shadow-lift);
  }
  .stories-fab svg { width: 24px; height: 24px; }

  /* ── Stories: a popup, not a sheet ──
     The thread floats free above the chat button — no panel, no
     backdrop, no page lock. Nothing is covered and nothing is
     dismissed to read a story: the map stays live underneath, so you
     can tap one pin after another and watch the bubbles arrive.

     The box has no fill of its own; the bubbles are the only thing
     drawn, which is what makes it read as a popup rather than a
     surface. It hugs its content upward from the dock and only starts
     scrolling once the thread outgrows 60dvh. */
  #messages {
    position: fixed;
    left: var(--gutter);
    right: var(--gutter);
    /* Clears the dock: its own lift, the button's height, and the same
       gap that separates the pill from the button. */
    bottom: calc(var(--phone-dock-bottom) + var(--phone-chat-btn) + var(--phone-dock-gap));
    z-index: 59;
    max-height: 60dvh;
    margin: 0;
    border-radius: 0;
    box-shadow: none;

    /* Not a panel — a wash. Without the sheet there is nothing between
       the bubbles and the page, and the caption under the map ran
       straight into the first bubble. The page colour fades in over the
       last --chat-fade of the box, so the text behind dissolves instead
       of colliding, and there is still no edge anywhere. The matching
       padding is what gives the fade its run above the top bubble. */
    padding: var(--chat-fade) 0 0;
    background: linear-gradient(to top,
                var(--bg) calc(100% - var(--chat-fade)), transparent);

    /* Rises and settles out of the button it was opened from, the way
       a chat popup does. --dur-slow over --ease-out is the gentlest
       pair the system has; scale is barely there on purpose, the
       movement should read as arriving, not as zooming. */
    opacity: 0;
    translate: 0 10px;
    scale: .97;
    transform-origin: bottom right;
    pointer-events: none;
    transition: opacity  var(--dur-slow) var(--ease-out),
                translate var(--dur-slow) var(--ease-out),
                scale     var(--dur-slow) var(--ease-out);
  }

  html.stories-open #messages {
    opacity: 1;
    translate: 0 0;
    scale: 1;
  }

  /* Only the bubbles take taps — the box spans the page width, and a
     transparent full-width box would swallow every tap meant for the
     map behind it. Scrolling a long thread therefore starts on a
     bubble, which is where a thumb lands anyway. */
  html.stories-open #messages .bubble,
  html.stories-open #messages .hint { pointer-events: auto; }

  /* The top fade assumes a tall column with the thread pinned to its
     bottom. This box is only as tall as its content, so the fade would
     land on the newest bubble instead of on a cut one. */
  #messages.has-message {
    mask-image: none;
    -webkit-mask-image: none;
  }

  /* ── The resting line, as a bubble ──
     In the rail it is a caption centred in an empty column. In the
     popup there is no column to caption — it is the thread's first
     message and looks like one: the same fill, ink, inset, measure and
     tail as .bubble, hugging the same side. Written against the bubble
     tokens rather than the class so the two can never drift apart.

     The empty-state rule centres the column's contents; a bubble hugs
     the side it was sent from, so that is overridden back here (id
     beats the two classes it is written with). */
  #messages:not(.has-message) .chat { align-items: flex-end; }

  #messages .hint {
    width: fit-content;
    max-width: var(--bubble-max);
    padding: var(--pad-bubble);
    background: var(--chat-bubble);
    color: var(--chat-bubble-ink);
    font-size: var(--fs-sm);
    line-height: var(--lh-snug);
    text-align: left;
    border-radius: var(--radius-bubble);
  }

  @media (prefers-reduced-motion: reduce) {
    #messages { transition: opacity var(--dur-fast) var(--ease); translate: none; scale: none; }
  }
}
