/* ═══════════════════════════════════════════════════════════════
   ElkanV6 — visionOS glass

   Glass is achromatic: it limits the range of background colour and
   borrows its tone from whatever sits behind it. Hierarchy comes from
   vibrancy tiers, not hue. No dark mode — visionOS has none.
   ═══════════════════════════════════════════════════════════════ */

:root {
  --label:   1;
  /* One height for every piece of cover artwork, posters and index tiles
     alike. See .poster and .tile__art. */
  --cover-h: 150px;
  --label-2: 0.62;
  --label-3: 0.32;

  --fill-1:    rgba(255, 255, 255, 0.18);
  --fill-2:    rgba(255, 255, 255, 0.12);
  --fill-3:    rgba(255, 255, 255, 0.07);
  --separator: rgba(255, 255, 255, 0.13);

  /* Softened, not round */
  --r-window: 16px;
  --r-card:   12px;
  --r-cell:   10px;
  --r-ctl:    10px;

  --font: -apple-system, BlinkMacSystemFont, "SF Pro Display", "SF Pro Text",
          "Inter", system-ui, sans-serif;

  --spring: cubic-bezier(0.22, 1, 0.36, 1);
  --hover:  0.18s ease;

  /* The deck stops growing here; past this the room opens up instead. */
  --deck-max:  1324px;   /* home deck: 400px side cards at the cap */
  --sheet-max: 1100px;   /* article card stays where he set it */
}

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

/* An author `display` rule beats the UA stylesheet's [hidden]{display:none},
   so a hidden scene would otherwise still paint over the visible one. */
[hidden] { display: none !important; }

html, body { height: 100%; background: #0e0c0a; }

body {
  font-family: var(--font);
  font-size: 16px;
  line-height: 1.45;
  font-weight: 400;
  letter-spacing: -0.01em;
  color: #fff;
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
  overflow: hidden;
}

/* ───────────────────────── Environment ───────────────────────── */

.environment { position: fixed; inset: 0; z-index: 0; }

/* A phone retracts its address bar as you scroll down and brings it back as
   you scroll up. A fixed element sized to the CURRENT viewport grows and
   shrinks with it, and `cover` re-fits the photograph every time — so the
   room silently zooms in and out while you read, which is exactly the thing
   a background must never do.

   lvh is the viewport with the browser chrome RETRACTED, i.e. the largest it
   ever gets. Pinned to that, the element never changes size at all: when the
   bar is showing it simply covers the foot of an image that was already
   bigger than the screen. dvh would be the bug itself; svh would leave a
   strip of nothing once the bar went away. */
@supports (height: 100lvh) {
  .environment { height: 100lvh; bottom: auto; }
  /* NOT the loader. It centres its handwriting with place-items, so making
     it taller than the screen would drop the writing below the optical
     centre — and it is only ever on screen before the page has scrolled,
     which is the one moment the address bar is guaranteed to be still. */
}

.environment__image {
  position: absolute; inset: -3%;
  background: var(--bg, url('assets/bg-afternoon.png')) center / cover;
  /* counter-drifts against the panels so the parallax has real depth */
  transform: translate3d(var(--ex, 0px), var(--ey, 0px), 0) scale(1.06);
  will-change: transform;
}

.environment__vignette {
  position: absolute; inset: 0;
  background:
    radial-gradient(130% 100% at 50% 42%, transparent 30%, rgba(0,0,0,0.34) 100%),
    rgba(0, 0, 0, 0.24);
}

/* ───────────────────────── Glass ───────────────────────── */

.glass {
  position: relative;
  isolation: isolate;
  border-radius: var(--r-window);
  color: #fff;

  background:
    linear-gradient(180deg,
      rgba(255,255,255,0.17) 0%,
      rgba(255,255,255,0.08) 32%,
      rgba(255,255,255,0.05) 100%),
    rgba(126, 130, 138, 0.24);

  -webkit-backdrop-filter: blur(44px) saturate(135%) brightness(1.05);
          backdrop-filter: blur(44px) saturate(135%) brightness(1.05);

  box-shadow:
    inset 0  1px 0 0   rgba(255,255,255,0.38),
    inset 0  0   0 0.5px rgba(255,255,255,0.14),
    inset 0 -1px 0 0   rgba(255,255,255,0.06);
}

@supports not ((backdrop-filter: blur(1px)) or (-webkit-backdrop-filter: blur(1px))) {
  .glass { background: rgba(90, 94, 102, 0.74); }
}


/* ───────────────────────── Stage ───────────────────────── */

.stage {
  position: relative;
  z-index: 1;
  height: 100%;
}

/* Tap-away: the cards leave for a beat and come back on their own.
   The fade is on each card, NEVER on the stage. An ancestor with opacity
   below 1 becomes a new backdrop root, and every backdrop-filter inside it
   then samples an empty backdrop instead of the room — which is why fading
   the stage brought the cards back as flat transparent shapes rather than
   glass. Fading the cards themselves leaves each one's backdrop root intact. */
.stage.is-away { pointer-events: none; }

.panel, .sheet {
  transition: opacity 0.42s var(--spring);
}
.stage.is-away .panel,
.stage.is-away .sheet,
.stage.is-away .back,
.stage.is-away .status {
  opacity: 0;
  transition: opacity 0.42s var(--spring);
}

.scene {
  position: absolute;
  inset: 0;
  display: grid;
  place-items: center;
  padding: clamp(16px, 3vh, 34px) clamp(20px, 4vw, 64px) clamp(56px, 11vh, 118px);
}

.deck {
  display: grid;
  grid-template-columns: 1fr 1.18fr 1fr;
  grid-template-rows: auto 1fr;
  column-gap: clamp(14px, 1.6vw, 26px);
  row-gap: 26px;   /* breathing room between the status line and the cards */
  width: 100%;
  min-width: 0;
  perspective: 1800px;          /* on the parent: children tilt, no preserve-3d */
  perspective-origin: 50% 50%;
}

/* Status line above the deck: weather over the left card, clock over the
   right. Same type as the section headings. */
.deckwrap {
  display: flex;
  flex-direction: column;
  gap: 12px;
  width: 100%;
  max-width: var(--deck-max);
  min-width: 0;
}

.status--left  { grid-column: 1; grid-row: 1; justify-self: start; }
.status--right { grid-column: 3; grid-row: 1; justify-self: end; }

.status {
  display: inline-flex;
  align-items: center;
  gap: 7px;
  font-size: 19px;            /* .panel__title */
  font-weight: 500;
  letter-spacing: -0.015em;
  line-height: 1.2;
  color: #fff;
  white-space: nowrap;
  font-variant-numeric: tabular-nums;   /* the minute changes without reflow */
  /* Held at zero until the first reading lands AND the room is on screen, so
     the line arrives with the cards rather than snapping in mid-fetch. */
  opacity: 0;
  transition: opacity 1.1s cubic-bezier(.32,.72,0,1);
}
.status.is-in { opacity: var(--label-2); }
.status:empty { display: none; }
.status svg { flex: 0 0 auto; }

/* The cloud is his artwork; the sun and the rain/snow/bolt marks are drawn
   over the same 24-unit box so the two stay registered to each other. */
.wx {
  position: relative;
  flex: 0 0 auto;
  width: 22px; height: 22px;
  display: inline-block;
}
.wx__cloud {
  position: absolute; left: 0; bottom: 2px;
  width: 100%; height: auto;
  display: block;
}
.wx--partly .wx__cloud { width: 82%; left: 18%; }
.wx__marks {
  position: absolute; inset: 0;
  width: 100%; height: 100%;
}

/* Each label faces the viewer on the same plane as the card beneath it, and
   drifts with the same parallax, so the line belongs to the card rather than
   floating flat on the glass. */
#weather, #clock { will-change: transform; }

#weather {
  transform: translate3d(var(--px, 0px), var(--py, 0px), 0)
             rotateY(10deg) translateZ(-18px);
  transform-origin: right center;
}
#clock {
  transform: translate3d(var(--px, 0px), var(--py, 0px), 0)
             rotateY(-10deg) translateZ(-18px);
  transform-origin: left center;
}

.panel {
  height: min(68vh, 620px);
  min-width: 0;
  overflow: hidden;
  will-change: transform;
}

/* Side panels face the viewer. --px/--py carry the cursor parallax and are
   composed with the resting rotation rather than replacing it. */
/* The sides sit further from the viewer than the centre, so an equal CSS
   height renders shorter. This adds back the perspective ratio (~2.6%) so the
   three cards line up on screen rather than in the stylesheet. */
.panel--left, .panel--right { height: min(69.8vh, 636px); }

.panel--left  { grid-column: 1; grid-row: 2; }
.panel--center{ grid-column: 2; grid-row: 2; }
.panel--right { grid-column: 3; grid-row: 2; }

.panel--left {
  transform: translate3d(var(--px, 0px), var(--py, 0px), 0)
             translateY(-8px) rotateY(10deg) translateZ(-18px);
  transform-origin: right center;
  animation: inLeft var(--in-dur, 0.85s) var(--spring) backwards 0.05s;
}
.panel--right {
  transform: translate3d(var(--px, 0px), var(--py, 0px), 0)
             translateY(-8px) rotateY(-10deg) translateZ(-18px);
  transform-origin: left center;
  animation: inRight var(--in-dur, 0.85s) var(--spring) backwards 0.15s;
}
.panel--center {
  /* same height as its neighbours — only the z-offset sets it forward */
  transform: translate3d(var(--px, 0px), var(--py, 0px), 0) translateZ(28px);
  animation: inCenter var(--in-dur, 0.85s) var(--spring) backwards 0.10s;
}

/* The end state has to match the panel's own transform EXACTLY, parallax
   vars included. It used to end on a fixed position, so the moment the
   animation released the element the base transform took over and the card
   jumped to wherever the cursor had already pushed it. Ending on the same
   expression means the card eases into the offset and the hand-off is
   invisible. Matching function lists on both sides keep it per-function
   rather than falling back to matrix interpolation. */
/* Out of a blur as well as out of nothing, ONCE — the cards resolve as the
   writing finishes, which is a moment of arrival and should not be repeated
   every time a scene changes. The blur and the longer duration are carried
   by variables that only exist while body.is-first does; every later
   entrance runs the plain, quicker version. */
body.is-first { --in-dur: 1.35s; --in-flat: 1.1s; --in-blur: 16px; }

@keyframes inLeft {
  from { opacity: 0; filter: blur(var(--in-blur, 0px));
         transform: translate3d(0px, 0px, 0) translateY(24px) rotateY(10deg) translateZ(-18px) scale(0.975); }
  to   { opacity: 1; filter: blur(0px);
         transform: translate3d(var(--px, 0px), var(--py, 0px), 0) translateY(-8px) rotateY(10deg) translateZ(-18px) scale(1); }
}
@keyframes inRight {
  from { opacity: 0; filter: blur(var(--in-blur, 0px));
         transform: translate3d(0px, 0px, 0) translateY(24px) rotateY(-10deg) translateZ(-18px) scale(0.975); }
  to   { opacity: 1; filter: blur(0px);
         transform: translate3d(var(--px, 0px), var(--py, 0px), 0) translateY(-8px) rotateY(-10deg) translateZ(-18px) scale(1); }
}
@keyframes inCenter {
  from { opacity: 0; filter: blur(var(--in-blur, 0px));
         transform: translate3d(0px, 0px, 0) translateY(24px) translateZ(28px) scale(0.975); }
  to   { opacity: 1; filter: blur(0px);
         transform: translate3d(var(--px, 0px), var(--py, 0px), 0) translateY(0px) translateZ(28px) scale(1); }
}
/* Content arriving in the body column, and photos arriving in the viewer.
   Short and small — this runs on every row click, so it has to feel like the
   page settling rather than an animation being played at you. */
/* Opacity only. A transform here promotes the entire article — up to eighty
   images — to its own layer for the duration, and that is what read as lag
   when stepping between rows. */
@keyframes paneIn {
  from { opacity: 0; }
}
.is-fresh { animation: paneIn 0.34s var(--spring) backwards; }

/* A staggered arrival instead of one flat fade: the heading, then the date,
   then each block under it, a beat apart. The pane itself no longer fades —
   two fades over each other read as a slow page rather than a settling one.
   Only the elements marked by stagger() move, so a rail of sixty photos is
   promoted for its own 0.44s and not for the length of the whole queue. */
@keyframes stepIn {
  from { opacity: 0; transform: translateY(9px); }
}
.is-fresh.is-stepped { animation: none; }
.is-fresh.is-stepped .step {
  animation: stepIn 0.44s var(--spring) backwards;
  animation-delay: calc(var(--i, 0) * 55ms);
}

@keyframes inFlat {
  from { opacity: 0; filter: blur(var(--in-blur, 0px)); transform: translateY(22px) scale(0.98); }
}

.panel__scroll {
  height: 100%;
  overflow-y: auto;
  overflow-x: hidden;
  overscroll-behavior: contain;
  --pad: clamp(26px, 2.2vw, 34px);
  padding: var(--pad);
  position: relative;
  z-index: 2;
  scrollbar-width: none;
}
.panel__scroll::-webkit-scrollbar { display: none; }

/* Content starts at the top of the card, like the other two.

   The index card insets its own children rather than its scroller, exactly as
   the article sidebar does: every line — heading, row label, cover — starts on
   one x, while the row's fill runs wider than that column toward both edges.
   19 + 14 keeps the text column where --pad had it. */
.panel__scroll--mid {
  display: block;
  padding-inline: 19px;
}
.panel__scroll--mid .block__head,
.panel__scroll--mid .block__label,
/* The tab strip is a heading too, so its rule starts and ends on the same
   x as the row text and the chevron rather than on the row's wider fill. */
.panel__scroll--mid .tabs { padding-inline: 14px; }
.panel__scroll--mid .row { padding-left: 14px; padding-right: 14px; }
.panel__scroll--center { display: flex; flex-direction: column; padding-bottom: 0; }

/* ───────────────────────── Type ───────────────────────── */

.panel__title {
  font-size: 19px;
  line-height: 1.24;
  font-weight: 500;
  letter-spacing: -0.015em;
  margin-bottom: 14px;
}
/* A rule where one body of text ends and the next begins. */
.panel__title--sub {
  margin-top: 26px;
  padding-top: 26px;
  border-top: 1px solid var(--separator);
}

/* Dimmed by colour rather than opacity: opacity applies to the whole
   subtree, so a link inside could never be brighter than its paragraph. */
.prose {
  --tight: 0.15em;
  font-size: 16px;
  line-height: 1.52;
  font-weight: 400;
  color: rgb(255 255 255 / var(--label-2));
}
/* A blank line in the note means a break; no blank line means the two blocks
   are one unit. So every block gets the same separation by default, and a
   block marked tight by the parser closes right up under the one above it —
   which is what keeps "Startups" attached to its own bullets while the air
   falls before the next label. */
/* Exactly one empty line — the same space a blank line in the note would
   occupy if it held text. 1lh IS the line box; the em value is the same
   number for browsers that do not know the unit. */
.prose p + p,
.prose p + ul,
.prose ul + p,
.prose ul + ul,
.prose h3 + ul { margin-top: 1.52em; margin-top: 1lh; }

.prose .is-tight { margin-top: var(--tight); }

/* A blank-line run he wrote in Obsidian, kept as real space: one empty line
   per extra blank, on top of the one the block gap already gives. */
.prose__gap {
  height: calc(var(--lines, 1) * 1.52em);
  height: calc(var(--lines, 1) * 1lh);
}
/* Body text is dimmed to 62% so links can be brighter than it; bold is the
   other thing he means to stand out, so it takes the same full white. */
.prose strong { font-weight: 600; color: #fff; }
/* A file the page hands over. Underlined, because that is what a link that
   does something looks like, and left to sit on its own line. */
.prose__dl {
  display: inline-block;
  margin: 0.6em 0 0.2em;
  color: #fff;
  text-decoration: underline;
  text-underline-offset: 3px;
  opacity: var(--label-2);
  transition: opacity var(--hover);
}
.prose__dl:hover { opacity: 1; }
/* "###" is a section header inside an article, and it is set like the
   section headers on a list card — Biohacking, Startup — so a heading
   reads the same weight wherever it appears on the site. */
.prose h3 {
  font-size: 22px;
  font-weight: 500;
  letter-spacing: -0.012em;
  color: #fff;
  margin: 1.5em 0 0.35em;
}
.prose h3:first-child { margin-top: 0; }
/* "##" sits above "###", so it has to stay bigger than it — with a
   hairline over it, so a long page reads as a handful of parts rather than
   one unbroken column of headings. */
.prose h2 {
  font-size: 27px;
  font-weight: 600;
  letter-spacing: -0.018em;
  color: #fff;
  margin: 2.1em 0 0.7em;
  padding-top: 1.1em;
  border-top: 0.5px solid rgba(255,255,255,0.14);
}
.prose h2:first-child { margin-top: 0; padding-top: 0; border-top: none; }
.prose h2 + h3 { margin-top: 0.2em; }
.prose ul { margin: 0.5em 0 0.5em 1.1em; }
.prose li { margin: 0.22em 0; }
.prose ul ul { margin: 0.25em 0 0.3em 1em; }

/* A rule between the three indexes, matching the one between Vision and
   About, so each card reads as sections rather than one long column. */
.block + .block {
  margin-top: 28px;
  padding-top: 28px;
  border-top: 1px solid var(--separator);
}

.block__label {
  font-size: 22px;
  font-weight: 500;
  letter-spacing: -0.012em;
  color: #fff;
  opacity: 1;
  margin: 20px 0 6px;
}
.block__label:first-of-type { margin-top: 2px; }


/* ───────────────────────── Hero ───────────────────────── */

/* The links sit on the name's centre line, not its baseline — at 27px
   against 14px a shared baseline leaves them hanging low. */
.hero { display: flex; align-items: center; justify-content: space-between;
        gap: 14px; flex-wrap: wrap; }

.hero__name {
  font-size: 27px;
  line-height: 1.1;
  font-weight: 500;              /* lighter — the bold read too heavy */
  letter-spacing: -0.022em;
}

.hero__links { display: flex; gap: 14px; flex-wrap: wrap; }

.hero__links a {
  font-size: 14px;
  font-weight: 400;
  letter-spacing: -0.01em;
  color: #fff;
  opacity: var(--label-2);
  text-decoration: none;
  transition: opacity var(--hover);
}
.hero__links a:hover { opacity: 1; }

/* Centred in what is left between the name and the divider, so the air above
   the picture matches the air below it. */
.hero__portrait {
  flex: 1 1 auto;
  display: flex;
  align-items: center;
  justify-content: center;
  min-height: 0;
  margin-top: 0;
}

/* Divider, section heading, and the two ways further in. */
.aboutme {
  flex: 0 0 auto;
  /* No margin: the slack inside .hero__portrait is split evenly above and
     below the picture, and a margin here would land entirely on one side. */
  margin-top: 0;
  /* Matched to the index card's rhythm: 28px from the rule to the heading,
     28px from the heading to the first row, 29px between rows. */
  padding-top: 25px;
  padding-bottom: var(--pad);
  border-top: 1px solid var(--separator);
}
.aboutme .panel__title { margin-bottom: 18px; }

/* Same scheme as the sidebar: the text column is the card's own padding and
   the row's fill reaches 14px past it on each side. The scroller's padding is
   33px, so the negative margin stays inside the padding box and nothing is
   clipped. */
.aboutme .rows { margin-inline: -14px; gap: 6px; }
.aboutme .row { padding: 7px 14px; }
/* A row that leaves the site is still a row: no underline, no link colour. */
.aboutme a.row { text-decoration: none; color: inherit; }

/* One subject at a time under "Let's chat about", fading from one to the
   next. The fade lives on an inner span so it cannot fight .row__text, which
   owns the row's own dim-and-brighten on hover. */
.chat__word {
  display: inline-block;
  transition: opacity 0.4s ease, transform 0.4s ease;
}
.chat__word.is-out { opacity: 0; transform: translateY(-4px); }
@media (prefers-reduced-motion: reduce) {
  .chat__word { transition: none; }
}
.prose__soon { opacity: var(--label-3); font-style: italic; }
.hero__portrait img {
  max-width: 100%; max-height: 100%;
  width: auto; height: auto;
  object-fit: contain;
  object-position: center;
}

/* ───────────────────────── Rows ───────────────────────── */

.rows { list-style: none; min-width: 0; display: flex; flex-direction: column; gap: 6px; }

.row {
  display: flex;
  align-items: center;
  gap: 11px;
  width: 100%;
  min-width: 0;
  min-height: 44px;
  padding: 7px 9px;
  border: none;
  border-radius: var(--r-cell);
  background: transparent;
  font: inherit;
  color: inherit;
  text-align: left;
  cursor: pointer;
  transition: background var(--hover);
}
/* Not yet readable: present, named, and plainly not a control. */
.row.is-draft, .tile.is-draft { cursor: default; }
.row.is-draft .row__text { opacity: var(--label-3); }
.row.is-draft:hover { background: transparent; }
.tile.is-draft .tile__title { color: rgb(255 255 255 / var(--label-3)); }
.tile.is-draft .tile__art { opacity: 0.45; }
.tile.is-draft:hover .tile__art { transform: none; background: var(--fill-2); }

.row:hover  { background: var(--fill-1); }
.row:active { background: rgba(255,255,255,0.26); }
/* No ring. A row is a wide soft pill and an outline traced around it read as
   a bug rather than as focus; the fill carries the state instead. */
.row:focus-visible { outline: none; background: rgba(255,255,255,0.20); }

.row__cover {
  /* Desaturated so a column of brand marks reads as one list rather than ten
     competing logos. The glass and the room carry the colour. */
  filter: grayscale(1);
  transition: filter var(--hover);
  flex: 0 0 auto;
  /* 16:9, because that is what the thumbnails are. The box used to be 58x38
     — 1.53:1 — so object-fit: cover shaved about 14% off each side and cut
     into logos that sit at the edge of the frame. */
  height: 38px;
  width: auto;
  aspect-ratio: 16 / 9;
  border-radius: 8px;
  object-fit: cover;
  background: var(--fill-2);
  box-shadow: inset 0 0 0 0.5px rgba(255,255,255,0.18);
}

/* Title over date in one column, the arrow beside the pair. */
.row__main {
  flex: 1 1 auto;
  min-width: 0;
  display: flex;
  flex-direction: column;
  gap: 1px;
}
.row__text {
  /* Still grows on its own: the Personal rows are built elsewhere and have
     no .row__main around them, and without this their arrow walks in to
     meet the text instead of holding the right edge. */
  flex: 1 1 auto;
  min-width: 0;
  font-size: 16px;
  font-weight: 400;
  letter-spacing: -0.012em;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
  opacity: var(--label-2);
}
.row:hover .row__text { opacity: 1; }
/* Colour is the reward for pointing at a row — the list stays monochrome. */
/* Colour returns on hover, and stays on the row you are reading — the
   greyscale is there to keep a column of logos calm, and the open one is
   no longer part of that column. */
.row:hover .row__cover,
.row[aria-current="true"] .row__cover { filter: none; }

.row__when {
  /* The same size and weight the gallery tiles give their dates, so a date
     reads the same wherever it appears. */
  font-size: 11.5px;
  line-height: 1.3;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
  opacity: var(--label-3);
}


/* The last row of a preview: a way out of the card, not a thing in it. Set
   back from the titles above it and divided from them, so the eye reads
   the list as having ended before it reads the way on. */
.row--all { margin-top: 2px; }
.row--all .row__text { opacity: var(--label-3); }
.row--all:hover .row__text { opacity: 0.86; }

/* A row without artwork keeps the same left edge as one with it. */
.row--plain { padding-left: 9px; }

/* The heading and its "View all" share one line. In the index card the
   inline padding is set alongside the rows' so the two line up on both
   edges — see .panel__scroll--mid above. */
.block__head {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: 12px;
  padding-right: 9px;
  margin-bottom: 14px;
}
.block__head .panel__title { margin-bottom: 0; }

/* ── Tabs ──────────────────────────────────────────────────────────────────
   One hairline under the whole strip, the active segment of it white. The
   labels share the width equally so the first sits at the left edge and the
   last at the right, which is what makes a row of three read as a control
   rather than as three loose words. */
.tabs {
  display: flex;
  margin: 0 0 13px;
  /* The inset is the index card's, set beside the headings' — see
     .panel__scroll--mid. The rule has to begin where "Product & Community,
     POD" begins and stop where its chevron stops; a row's fill is wider than
     its text column, and a line drawn to the fill overhangs both ends. */
}
.tabs__btn {
  flex: 1 1 0;
  min-width: 0;
  padding: 1px 2px 8px;
  border: none;
  border-bottom: 1.5px solid rgba(255,255,255,0.16);
  background: none;
  color: #fff;
  font: inherit;
  font-size: 14px;
  font-weight: 400;
  letter-spacing: -0.012em;
  text-align: center;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
  opacity: var(--label-3);
  cursor: pointer;
  transition: opacity var(--hover), border-color var(--hover);
}
.tabs__btn:hover { opacity: 0.82; }
.tabs__btn[aria-selected="true"] {
  opacity: 1;
  border-bottom-color: #fff;
}
.tabs__btn:focus-visible { outline: none; opacity: 1; }
@media (hover: none) {
  .tabs__btn:not([aria-selected="true"]):hover { opacity: var(--label-3); }
}

/* An embedded playlist. Sized by the service, so the only thing to say here
   is how wide it is and that it keeps the card's corner radius. */
.prose__embed {
  display: block;
  width: 100%;
  height: 352px;
  border: 0;
  border-radius: 12px;
  margin: 1.1em 0 0.4em;
  background: var(--fill-2);
}

.seeall {
  position: relative;
  z-index: 1;
  flex: 0 0 auto;
  padding: 2px 0;
  border: none;
  background: none;
  color: #fff;
  font: inherit;
  font-size: 14px;            /* same as .row__text */
  font-weight: 400;
  letter-spacing: -0.012em;
  opacity: var(--label-3);
  cursor: pointer;
  transition: opacity var(--hover);
}
.seeall:hover { opacity: 1; }
.seeall:focus-visible { outline: 2px solid rgba(255,255,255,0.7); outline-offset: 2px; }

/* ── Scroll cue ────────────────────────────────────────────────────────────
   "There is more below." A cue, not a control: it says what the card can do
   and gets out of the way the moment the reader does it.

   It does not loop. Motion here is arrival — a chevron bouncing for as long
   as you look at it would be the only thing on screen that never settles,
   and the eye stops reading it within seconds anyway. What makes it register
   is that it is the one thing that FADES when you scroll.

   Inside the card, so no backdrop-filter of its own: a second filter nested
   in the glass samples the already-composited parent and goes muddy. */
.scrollcue {
  position: absolute;
  left: 50%;
  bottom: 9px;
  z-index: 3;
  width: 26px; height: 26px;
  display: grid; place-items: center;
  transform: translate3d(-50%, 0, 0);
  color: #fff;
  opacity: 0;
  pointer-events: none;
  transition: opacity 0.35s ease;
}
/* A halo rather than a band. The cue sits over whatever the last row happens
   to be, and a scrim wide enough to guarantee contrast reads as a dark stripe
   across the foot of the card; this is invisible as a shape and still keeps
   the glyph off the text behind it. */
.scrollcue::before {
  content: '';
  position: absolute;
  inset: -7px;
  border-radius: 50%;
  background: radial-gradient(closest-side, rgba(0,0,0,0.30), transparent 100%);
}
.scrollcue svg { position: relative; }
.scrollcue.is-on { opacity: var(--label-3); }

/* ───────────────────────── Sheets (list / article) ───────────────────────── */

/* The scene centres a row whose height is the card's. Because the row is
   sized by the card rather than the viewport, `flex-start` puts the button on
   the card's top edge — and the card keeps the same footprint as the home
   deck instead of filling the screen. */
.scene--stack { display: grid; place-items: center; }

.stackrow {
  display: flex;
  flex-direction: row;
  align-items: flex-start;
  justify-content: center;
  gap: 14px;
  width: 100%;
  max-width: calc(var(--sheet-max) + 130px);
  min-width: 0;
  min-height: 0;
}

/* Separate from the card and left-aligned, rather than floating over it */
.back {
  align-self: flex-start;
  display: inline-flex;
  align-items: center;
  gap: 8px;
  height: 42px;
  padding: 0 16px 0 13px;
  border: none;
  border-radius: var(--r-ctl);
  background:
    linear-gradient(180deg,
      rgba(255,255,255,0.17) 0%,
      rgba(255,255,255,0.08) 32%,
      rgba(255,255,255,0.05) 100%),
    rgba(126, 130, 138, 0.24);
  -webkit-backdrop-filter: blur(44px) saturate(135%) brightness(1.05);
          backdrop-filter: blur(44px) saturate(135%) brightness(1.05);
  box-shadow:
    inset 0 1px 0 rgba(255,255,255,0.32),
    inset 0 0 0 0.5px rgba(255,255,255,0.14);
  color: #fff;
  font: inherit;
  font-size: 14px;
  cursor: pointer;
  transition: background var(--hover), opacity 0.42s var(--spring);
  flex: 0 0 auto;
  transform: translate3d(var(--px, 0px), var(--py, 0px), 0);
  will-change: transform;
  /* Arrives with the card instead of snapping in beside it. A beat behind,
     so the card is what you see land and the button follows. inFlat has no
     `to`, so it finishes on the parallax transform above. */
  animation: inFlat 0.6s var(--spring) backwards 0.08s;
}
.back:hover { background: rgba(150,154,162,0.30); }

/* The story's list heading is a label, not a way back to a gallery. */
.sideTitle--static { cursor: default; }

/* An article puts its date between the title and the body; a story page has
   nothing there, so the title needs its own air or the text runs straight
   into it. */
#storyPageBody { margin-top: 20px; }
#storyPageBody > .panel__title:first-child { margin-top: 0; }

/* On desktop the control collapses to a square: the chevron alone carries the
   meaning, and the word was spending ~95px of the row to repeat what the card
   it returns to already says. The row narrows by the same amount so the card
   keeps its position rather than sliding right. */
@media (min-width: 901px) {
  /* The identity card used to be a name and a portrait with nothing below
     the fold, so scrolling it only ever moved the picture by accident and
     was turned off here. It carries the Personal list now, and it scrolls
     like the other two.

     The floor is what makes that mean anything. The portrait is flex: 1 1
     auto with no minimum, so on a short window it gave up its own height
     until everything fitted — 157px of face on a 620px screen, and a card
     that could never scroll because it had already squeezed itself to size.
     With a floor it stops shrinking, the content runs past the bottom, and
     the reader scrolls to the list instead of squinting at the picture. */
  #home .panel--left .hero__portrait { min-height: min(38vh, 330px); }

  .back { width: 42px; padding: 0; gap: 0; justify-content: center; }
  .back > span { display: none; }

  /* On a wide card the stamp belongs in the corner, clear of the reading
     column — there is room for it there and none under the title. It is a
     flex sibling of the title, not an absolute box with 170px of padding
     held open for it: a fixed reservation is paid by EVERY title, so
     "AcademicOS - Context-aware learning" wrapped to two lines with half
     the card empty beside it. As siblings the title takes whatever is
     actually left and wraps only when it truly meets the stamp. */
  /* The id is here to out-rank the base .article__updated further down the
     file, which sets its own margin and would otherwise undo the nudge. */
  #articlePane .article__updated {
    flex: 0 0 auto;
    /* 3px, not 0: aligned on the two CAP HEIGHTS rather than the two boxes.
       A 13px line's cap sits higher in its box than a 28px one's, so flush
       boxes put the small text visibly above the title. */
    margin: 3px 0 0;
    text-align: right;
    white-space: nowrap;
  }
  .stackrow { max-width: calc(var(--sheet-max) + 56px); }
}

.sheet {
  /* Drifts with the pointer like the deck does. inFlat has no `to`, so the
     entrance animation lands on whatever this expression evaluates to and the
     hand-off needs no special casing. */
  transform: translate3d(var(--px, 0px), var(--py, 0px), 0);
  will-change: transform;
  width: 100%;
  max-width: var(--sheet-max);
  margin: 0 auto;
  flex: 1 1 auto;
  min-width: 0;
  min-height: 0;
  height: min(74vh, 680px);      /* same footprint as the home deck */
  overflow: hidden;
  animation: inFlat var(--in-flat, 0.6s) var(--spring) backwards;
}

/* Both columns must be allowed to shrink below their content, or the grid
   row grows to the tallest child and the card overflows again. */
.sheet--split > * { min-height: 0; }
.sheet__body { display: flex; flex-direction: column; }

/* --side-w is set by dragging the grip; until then the sidebar sizes itself. */
/* The gallery is the whole card. Showing it beside the sidebar put the same
   collection on screen twice — the list on the left and the same list as
   tiles on the right — so the sidebar steps aside and comes back the moment
   a tile is opened. */
.sheet--split.is-gallery { grid-template-columns: 1fr; }
.sheet--split.is-gallery .sheet__side,
.sheet--split.is-gallery .side__grip { display: none; }

.sheet--split {
  display: grid;
  /* 37% of the card is the width he settled on by dragging. The 300px floor
     keeps it usable on a narrow card, and the grip can take it to half. */
  grid-template-columns: var(--side-w, minmax(300px, 37%)) 1fr;
}

/* The seam between the two panes, widened into something you can grab. */
.side__grip {
  position: absolute;
  top: 0; bottom: 0; right: 0;
  width: 10px;
  z-index: 5;
  cursor: col-resize;
  background: none;
  border: none;
  padding: 0;
}
.side__grip::after {
  content: '';
  position: absolute;
  top: 0; bottom: 0; right: 0;
  width: 2px;
  background: transparent;
  transition: background var(--hover);
}
.side__grip:hover::after,
body.is-resizing .side__grip::after { background: rgba(255,255,255,0.40); }

/* While dragging, the whole window keeps the resize cursor and stops trying
   to select the text the pointer sweeps over. */
body.is-resizing { cursor: col-resize; user-select: none; -webkit-user-select: none; }

/* A nested panel must not re-run backdrop-filter — it would sample the
   already-composited parent and go muddy. Plain fill only. */
.sheet__side {
  /* The top inset must match .panel__scroll's --pad exactly, or "Library" and
     the article title beside it sit at different heights. The sides stay
     narrower — the column is only 300px wide. */
  /* The horizontal inset lives on the rows, not here: pulling the rows wider
     with a negative margin put them outside this box, and the list's own
     overflow then cut the highlight off at both ends. */
  --side-inset: clamp(26px, 2.2vw, 34px);
  /* No bottom inset on the pane: it would end the scrollport short of the
     card and leave rows clipped in mid air. The list carries that space as
     its own padding instead, so it scrolls right down to the card's edge and
     still has room after the final row. */
  padding: var(--side-inset) 13px 0;
  /* The heading holds still and the list scrolls beneath it: aligning a group
     to the top used to drag "Library" and its View all icon off the panel. */
  display: flex;
  flex-direction: column;
  overflow: hidden;
  position: relative;
  z-index: 2;
  min-width: 0;
  /* A shade deeper than the reading column, the way a sidebar reads in the
     system's own split views — the two planes separate without a hard line. */
  background: rgba(0, 0, 0, 0.16);
  border-right: 1px solid var(--separator);
}
.sheet__side::-webkit-scrollbar { display: none; }
.sheet__body { position: relative; z-index: 2; min-width: 0; }

.grouped > .block__label:first-child { margin-top: 2px; }

/* The sidebar carries dates like every other list. It used to be the one
   exception, from when the date sat beside the title and the narrowest
   column on the site could not hold both; under the title it costs the
   sidebar nothing the other lists are not already paying. */

.row__chev { flex: 0 0 auto; opacity: var(--label-3); }
.row:hover .row__chev { opacity: var(--label-2); }

/* One line per row, truncated. The panel is resizable, so a title that does
   not fit is a reason to widen the panel rather than to wrap. */
.grouped--side .row__text {
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
  line-height: 1.3;
}

/* The group heading sits at the row's own size: it labels them, it does not
   outrank the article. */
/* Spacing is reckoned between the TEXT, not the boxes: a row carries 8px of
   padding inside its fill, so equal box gaps read as unequal lines. 13px below
   the heading puts its first row the same distance away as the rows are from
   each other; 36px above opens a clear break between groups. */
/* One size for a section heading everywhere: the sidebar used to drop to the
   row's size, which no longer matches the rest of the site. */
.grouped--side .block__label {
  margin: 36px 0 13px;
}
/* Every line in the sidebar — title, group heading, row label — starts on the
   same x, set by this one inset. The row carries it as padding rather than
   margin, so its fill spans the full width of the panel's content box and
   still leaves the text where the headings are. */
.side__head,
.grouped--side .block__label { padding-inline: 14px; }

/* One row size across the site. The sidebar used to shrink its rows and
   their covers to fit more in; next to the deck's rows that read as a
   different control, so it takes the deck's geometry — only the 14px inset
   is its own, because every line in the panel starts on that x. */
.grouped--side .row {
  align-items: center;
  padding-inline: 14px;
}
.grouped--side .row[aria-current="true"] { background: var(--fill-1); }
/* The fill already says which row is current; re-weighting the text as well
   both over-stated it and reflowed the rows around it. */
.grouped--side .row[aria-current="true"] .row__text,
.grouped--side .row[aria-current="true"] .row__chev { opacity: 1; }

/* Title left, "last updated" right, sharing a baseline so the small label
   sits on the title's line rather than floating above it. */
.article__head {
  display: flex;
  align-items: flex-start;
  justify-content: space-between;
  gap: 20px;
}
/* One line under the title, where it reads as a note about the page rather
   than a label pinned to the corner. */
.article__updated {
  margin: 0 0 2px;
  font-size: 13px;
  line-height: 1.3;
  letter-spacing: -0.008em;
  opacity: var(--label-3);
}
.article__updated:empty { display: none; }
/* A handheld has no room beside the title, so the head stops being a row
   and the stamp falls under it, as it did when it was a separate block. */
@media (max-width: 900px) {
  .article__head { display: block; }
}

.article__title {
  min-width: 0;
  font-size: 28px;
  line-height: 1.18;
  font-weight: 500;
  letter-spacing: -0.022em;
  margin-bottom: 6px;
}
/* Sized and dimmed like the body text under it — it is a line of the
   article, not a caption on the title. Colour rather than opacity, for the
   same reason .prose uses colour. */
.article__meta {
  font-size: 16px;
  line-height: 1.52;
  color: rgb(255 255 255 / var(--label-2));
  margin-bottom: 22px;
}

/* An article splits its text into one .prose per block, so the gap BETWEEN
   blocks has to match the gap between paragraphs inside one — and a picture
   or a rail is a block like any other, so it takes the same single line of
   air. Set here rather than on each kind, because the pairs multiply: prose
   to rail, rail to prose, picture to heading.
   --air is the body text's own line box, held as a value rather than as
   `1lh` because `lh` resolves against each ELEMENT's font — on a 22px shelf
   heading that came out as 33px, not the 24px every other block gets. */
#detailBody { font-size: 16px; line-height: 1.52; --air: calc(16px * 1.52); }
#detailBody > * + * { margin-top: var(--air); }

/* A rail carries 8px of invisible headroom top and bottom so a tile growing
   on hover is not clipped. Each kind of rail cancelled that differently —
   the poster rail with -8px, the photo rail with +10px — which put the same
   two neighbours at different distances. Both are cleared here so the rules
   below are the only thing setting the distance, and both subtract the 8px
   of padding so the OPTICAL gap, picture edge to text, is one line. */
#detailBody > .carousel { margin-block: 0; }
#detailBody > * + .carousel,
#detailBody > .carousel + * { margin-top: calc(var(--air) - 8px); }

/* A shelf heading belongs to the rail beneath it, not to the text above. */
#detailBody > .block__label + .carousel { margin-top: -2px; }

/* Where to go when the page runs out. A rule, a quiet label, and the rows
   that follow this one in the sidebar — the same rows, so the reader is
   handed the list they were already in rather than a new idea. */
.nextup {
  padding-top: 24px;
  border-top: 1px solid var(--separator);
}
/* A wider break than between two blocks of the article — this is the end of
   the piece, not another paragraph of it. Scoped to beat the generic
   "#detailBody > * + *" rhythm, and repeated for the case where a rail runs
   right up to it, which cancels 8px of its own. */
/* A blank-line run he typed in Obsidian, kept as real space: the block's
   usual rhythm plus one empty line, which is what two blank lines mean
   everywhere else in a note. */
#detailBody > .is-gapped { margin-top: calc(var(--air) + 1.52em); }
#detailBody > .is-gapped { margin-top: calc(var(--air) + 1lh); }

#detailBody > .nextup { margin-top: calc(var(--air) * 1.6); }
#detailBody > .carousel + .nextup { margin-top: calc(var(--air) * 1.6 - 8px); }
.nextup__label {
  font-size: 16px;
  font-weight: 500;
  letter-spacing: -0.012em;
  color: #fff;
  margin: 0 0 8px;
}
/* The rows reach past the text column on both sides, the way they do in the
   sidebar, so the fill reads as a control and the labels stay on the text's
   own x. */
.nextup .rows { margin-inline: -14px; gap: 2px; }
.nextup .row { padding: 7px 14px; }

/* Links inside prose: the vibrancy tier already dims the text, so the link
   is marked by a hairline rule rather than a second colour. */
.prose a {
  color: #fff;
  text-decoration: underline;
  text-decoration-color: rgba(255,255,255,0.55);
  text-decoration-thickness: 1.5px;
  text-underline-offset: 3px;
  transition: text-decoration-color var(--hover);
}
.prose a:hover { text-decoration-color: #fff; }
.prose a:focus-visible { outline: 2px solid rgba(255,255,255,0.7); outline-offset: 2px; border-radius: 3px; }

/* Every photo in an article is a rail tile now, so the full-width figure
   this styled is no longer emitted. Kept for the photo viewer, which still
   draws a bare <img> inside .shot's frame rules. */
.shot { margin: 18px 0; }
.shot img {
  display: block;
  width: 100%;
  height: auto;
  border-radius: var(--r-card);
  box-shadow: inset 0 0 0 0.5px rgba(255,255,255,0.14);
}
/* No max-width here: the body column is 800px, so a 760px cap left 40px of
   dead space on the right and the padding read as lopsided. The column's own
   width gives a 732px measure, which is already a comfortable one. */


/* ───────────────────────── Gallery ─────────────────────────
   One horizontal carousel per category. Native scroll with snap rather than
   a JS slider: it keeps trackpad, wheel-shift, touch and keyboard working,
   and degrades to a plain scroller if snap is unsupported. */

.gallery { display: flex; flex-direction: column; gap: 26px; }

.shelf__head {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: 12px;
  margin-bottom: 10px;
}

.carousel {
  display: flex;
  gap: var(--rail-gap, 14px);
  overflow-x: auto;
  overflow-y: hidden;
  min-width: 0;
  scroll-snap-type: x proximity;
  --per: 4;            /* tiles visible across the rail */
  --rail-gap: 14px;
  margin-inline: calc(-1 * var(--pad, 30px));
  padding-inline: var(--pad, 30px);
  scroll-padding-left: var(--pad, 30px);
  overscroll-behavior-x: contain;
  scrollbar-width: none;
  /* A horizontal scroller has to clip vertically, and it clips at the padding
     box — so a tile that grows on hover loses its top and bottom edges unless
     the rail carries headroom for it. The negative margin keeps the layout
     where it was. */
  padding-block: 8px;
  margin-block: -8px;
}
.carousel::-webkit-scrollbar { display: none; }

.tile {
  /* Sized from its cover, which is --cover-h tall like every other piece of
     artwork on a shelf; the caption takes the width the picture sets. */
  flex: 0 0 auto;
  width: auto;
  min-width: 0;
  scroll-snap-align: start;
  display: flex;
  flex-direction: column;
  gap: 9px;
  padding: 0;
  border: none;
  background: none;
  font: inherit;
  color: inherit;
  text-align: left;
  cursor: pointer;
}
.tile:focus-visible { outline: 2px solid rgba(255,255,255,0.7); outline-offset: 3px; border-radius: 12px; }

.tile__art {
  position: relative;
  height: var(--cover-h);
  width: auto;
  aspect-ratio: 16 / 9;   /* matches the 800px thumbs exactly */
  border-radius: 10px;
  overflow: hidden;
  background: var(--fill-2);
  box-shadow: inset 0 0 0 0.5px rgba(255,255,255,0.16);
  transition: transform var(--hover), background var(--hover);
}
.tile__art img { width: 100%; height: 100%; object-fit: cover; display: block; }
.tile:hover .tile__art { transform: scale(1.018); background: var(--fill-1); }
.tile:active .tile__art { transform: scale(0.994); }
.shotcell:active, .poster:active { transform: scale(0.992); }

/* Notes carry no artwork, so the tile sets its title on plain glass instead
   of leaving an empty frame. */
.tile__art--text {
  display: grid;
  place-items: center;
  padding: 14px;
}
.tile__art--text span {
  font-size: 14px;
  font-weight: 500;
  letter-spacing: -0.012em;
  text-align: center;
  line-height: 1.3;
  opacity: var(--label-2);
}

/* Body text, the same size and dim as a paragraph — a tile's caption is
   reading matter, not a label on a control. Colour rather than opacity, so
   the date beneath can be dimmer still without compounding. */
.tile__title {
  font-size: 16px;
  font-weight: 400;
  letter-spacing: -0.012em;
  line-height: 1.3;
  color: rgb(255 255 255 / var(--label-2));
}
.tile:hover .tile__title { color: #fff; }
/* The three parts of a tile sit on one 9px rhythm, but a rhythm reckoned
   between boxes is not what the eye reads. Above the title the gap runs
   from a hard picture edge to the cap; below it, from the BASELINE to the
   next cap, picking up the title's descender space and the date's leading
   on the way — 16.5px against 13.3px. The 3px closes that. */
.tile__when {
  font-size: 11.5px;
  line-height: 1.3;
  opacity: var(--label-3);
  margin-top: -3px;
}

/* ── The food wall ─────────────────────────────────────────────────────────
   A masonry: every tile the width of its column, as tall as its picture
   asks, dropped into whichever column is shortest. Absolutely placed, so
   302 of them are one layout pass rather than 302 — and placed by transform,
   which the compositor can move without touching layout at all. */
.masonry {
  position: relative;
  width: 100%;
  margin: 0.6em 0 0.2em;
}
.masonry__cell {
  position: absolute;
  top: 0;
  left: 0;
  padding: 3px;
  /* Visible unless something takes it away. Hiding every tile and waiting
     for an IntersectionObserver to give them back means a browser without
     one — or a tab that never composites — shows an empty wall. */
  transform: translate3d(var(--x, 0px), var(--y, 0px), 0);
}
.masonry__cell img {
  display: block;
  width: 100%;
  height: 100%;
  object-fit: cover;
  border-radius: 8px;
  background: var(--fill-2);
}
/* Reaching the screen is what starts it, not the page loading: a stagger
   across all 302 would run for a quarter of an hour. */
@keyframes foodIn {
  from { opacity: 0; filter: blur(8px); }
  to   { opacity: 1; filter: blur(0px); }
}
.masonry__cell.is-in {
  /* backwards, so the blurred first frame holds through the stagger delay
     instead of the tile showing itself and then starting over. */
  animation: foodIn 0.5s var(--spring) backwards;
  animation-delay: calc(var(--i, 0) * 45ms);
}
@media (prefers-reduced-motion: reduce) {
  .masonry__cell.is-in { animation: none; opacity: 1; }
}

/* ── Poster shelf ──────────────────────────────────────────────────────────
   Artwork only, the way a TV shelf works: one uniform 2:3 crop per title and
   no caption, because the poster already says what it is. */
.carousel--posters { --per: 6; }

/* Artwork on a shelf is one height everywhere — a poster and an index
   cover sit at the same line whichever rail you are looking at. Height is
   the fixed side and the width follows the aspect ratio, so nothing is
   cropped to make the two agree: a 2:3 poster comes out narrow and a 16:9
   cover wide, both exactly --cover-h tall. */
.poster {
  flex: 0 0 auto;
  height: var(--cover-h);
  width: auto;
  aspect-ratio: 2 / 3;
  scroll-snap-align: start;
  border-radius: var(--r-cell);
  overflow: hidden;
  background: var(--fill-2);
  box-shadow: inset 0 0 0 0.5px rgba(255,255,255,0.16);
  transition: transform var(--hover);
}
.poster img {
  display: block;
  width: 100%;
  height: auto;
  aspect-ratio: 2 / 3;
  object-fit: cover;
}
.poster:hover { transform: scale(1.018); }

/* ── Photo rail ────────────────────────────────────────────────────────────
   Consecutive photos in an article scroll sideways instead of stacking: some
   runs are sixty images long, and a column of those buries the writing. */
.carousel--shots {
  margin-block: 10px;        /* 18px of air, less the 8px of hover headroom */
  /* The rail bleeds past the card padding, so the tiles are sized against a
     narrower box than the one you see; 2.3 is what puts 2.5 on screen. */
  --per: 2.3;
  --rail-gap: 10px;
  scroll-snap-type: none;
  /* Some entries carry sixty photos in a column of rails. Skipping layout and
     paint for the ones off screen is what keeps switching rows instant. */
  content-visibility: auto;
  contain-intrinsic-size: auto 130px;
}

/* --cover-h tall like every other piece of artwork on a shelf, but with NO
   frame ratio of its own: the cell takes the width the photograph asks for,
   so a wide shot comes out wide and a portrait narrow and neither loses an
   edge. A fixed 16:9 frame cropped whatever did not fit it. */
.shotcell {
  flex: 0 0 auto;
  padding: 0;
  border: none;
  cursor: pointer;
  transition: transform var(--hover);
  height: var(--cover-h);
  width: auto;
  min-width: 0;
  border-radius: var(--r-cell);
  overflow: hidden;
  background: var(--fill-2);
  box-shadow: inset 0 0 0 0.5px rgba(255,255,255,0.14);
}
.shotcell img { display: block; height: 100%; width: auto; max-width: none; }
.shotcell:hover { transform: scale(1.015); }
.shotcell:focus-visible { outline: 2px solid rgba(255,255,255,0.7); outline-offset: 3px; }

/* ── Photo viewer ──────────────────────────────────────────────────────────
   Same card footprint and the same back button position as the article, so
   opening a photo reads as the card changing rather than a new place. */
/* A column: the frame takes what is left after the counter, and the image is
   fitted INTO the frame rather than sized by it.

   It used to rely on max-height:100% to stop a tall photo, but that percentage
   has to resolve through a chain of percentage heights and does not — so only
   the width was ever constrained and a 4:3 photo ran 144px past the card. */
.sheet--photo {
  position: relative;
  display: flex;
  flex-direction: column;
  gap: 10px;
  padding: clamp(18px, 1.6vw, 26px);
}
/* The reel: the run of photos stacked one under the next at the card's
   full width, in colour, with nothing between them but a hairline of air.
   A plain scroll — no snapping, no ring, no neighbour effects. */
.photo__reel {
  flex: 1 1 auto;
  min-height: 0;
  overflow-y: auto;
  overflow-x: hidden;
  overscroll-behavior: contain;
  display: flex;
  flex-direction: column;
  gap: 10px;
  scrollbar-width: none;
}
.photo__reel::-webkit-scrollbar { display: none; }
/* Held still while a photo is pinched open: the finger on it is panning
   the picture, not moving the column — and a scaled image contributes its
   own overflow to the scroll range, which would let the column run on
   past its last photo. */
.photo__reel.is-locked { overflow: hidden; }

/* No fixed height any more. The cell is as tall as its picture, which is
   what made the old reel read as a carousel: a landscape shot inside a
   330px box left most of the box empty, and the neighbours were scaled to
   72% on top of that. The intrinsic size is set on the <img> in buildReel,
   so the column has its full height before a single file has arrived. */
.photo__cell {
  flex: 0 0 auto;
  width: 100%;
  display: grid;
  place-items: center;
}
.photo__cell img {
  display: block;
  /* ONE width for every photo in the column.

     It used to be width/height auto with a max of 100%, which means an
     image stops at its OWN pixel size — so a deck whose first page was
     exported at 1600px and whose other five were exported at 720 stepped
     in and out by 130px down the column, with nothing on screen to explain
     why. Six identically shaped slides at six different widths.

     Width is the constant now and the height follows the aspect. --nat is
     the picture's own width, written on the cell in buildReel: the ceiling
     stops a genuinely small file from being blown up past the point where
     the softness shows, which is the one case worth being ragged for. */
  width: min(92%, 820px, calc(var(--nat, 9999px) * 1.3));
  height: auto;
  /* Short enough that the next photo is already showing underneath it.
     object-fit keeps a tall picture's shape when this is what binds
     instead of the width. */
  max-height: min(66vh, 560px);
  object-fit: contain;
  border-radius: var(--r-card);
  box-shadow: inset 0 0 0 0.5px rgba(255,255,255,0.14);
  transform-origin: center center;
}
/* Pinched open: it grows out of its own cell and the reel clips it, so the
   card keeps its size and the room behind it never moves. */
.photo__cell.is-zoomed img {
  position: relative;
  z-index: 1;
  cursor: grab;
}

.photo__nav {
  position: absolute;
  top: 50%;
  transform: translateY(-50%);
  width: 42px; height: 42px;      /* the same square as the back button */
  display: grid; place-items: center;
  border: none;
  border-radius: var(--r-ctl);
  background:
    linear-gradient(180deg,
      rgba(255,255,255,0.17) 0%,
      rgba(255,255,255,0.08) 32%,
      rgba(255,255,255,0.05) 100%),
    rgba(126, 130, 138, 0.30);
  -webkit-backdrop-filter: blur(24px) saturate(135%) brightness(1.05);
          backdrop-filter: blur(24px) saturate(135%) brightness(1.05);
  box-shadow:
    inset 0 1px 0 rgba(255,255,255,0.32),
    inset 0 0 0 0.5px rgba(255,255,255,0.14);
  color: #fff;
  cursor: pointer;
  transition: background var(--hover);
}
.photo__nav:hover { background: rgba(150,154,162,0.38); }
.photo__nav:focus-visible { outline: 2px solid rgba(255,255,255,0.7); outline-offset: 2px; }
.photo__nav--prev { left: 16px; }
.photo__nav--next { right: 16px; }

/* In flow at the foot of the column, so it can never sit over the picture. */
.photo__count {
  flex: 0 0 auto;
  align-self: center;
  font-size: 13px;
  font-variant-numeric: tabular-nums;
  opacity: var(--label-3);
  pointer-events: none;
}

/* Title and the way into the gallery share one line.

   The space below the title uses the same inset the card gives above it, so
   the rule sits as far from "Experience" as "Experience" sits from the card's
   top edge. The rule itself only appears once the list has been scrolled —
   there is nothing to divide the title from until something passes under it. */
.side__head {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 12px;
  padding-bottom: var(--side-inset);
  /* No margin under the rule: a gap there is dead space that scrolling rows
     are clipped before reaching, so they appear to stop short of the line.
     The breathing room moves inside the list as padding instead. */
  border-bottom: 1px solid transparent;
  transition: border-color 0.25s linear;
}
.sheet__side.is-scrolled .side__head { border-bottom-color: var(--separator); }

/* "View all" moved out of the sidebar heading and down beside the back
   button, because the list it opens replaces the whole card — a control
   that dismisses the sidebar does not belong inside it. */
.stackrow__rail {
  flex: 0 0 auto;
  align-self: flex-start;
  display: flex;
  flex-direction: column;
  gap: 10px;
}
/* Square at every width: it carries an icon and no word, so the pill shape
   the back button takes on a handheld would be mostly empty. */
.back--square { width: 42px; padding: 0; gap: 0; justify-content: center; }

/* The section name and the menu are the handheld rail's own buttons; on a
   wide screen the sidebar names the section and there is no menu. */
.back--text, .back--menu { display: none; }
.back--text { font-size: 15px; }
/* A label wearing the button's glass — it names where you are, it does not
   do anything. */
.back--static { cursor: default; }

/* The hamburger, drawn rather than typed, so it sits at the same optical
   weight as the glyphs beside it. Base CSS, not inside a media query: it is
   used by the deck's row, the article rail and the old sticky bar, and when
   it lived in the bar's own block the icon simply did not render anywhere
   else — an empty glass square. */
.navbar__bars { position: relative; }
/* Drawn to the back chevron's measurements so the two read as one set.
   That arrow is a 1.9-wide stroke on a 24 viewBox shown at 19px — 1.5px on
   screen — and its path spans 13 of those units, so 10.3px tall. Three
   1.5px bars make that height at an offset of 4.4px, and both glyphs are
   centred in their 42px square, so they sit on the same line. */
.navbar__bars,
.navbar__bars::before,
.navbar__bars::after {
  display: block;
  width: 18px; height: 1.5px;
  border-radius: 1px;
  background: currentColor;
  transition: transform 0.26s var(--spring), opacity 0.16s linear;
}
.navbar__bars::before, .navbar__bars::after { content: ''; position: absolute; }
.navbar__bars::before { transform: translateY(-4.4px); }
.navbar__bars::after  { transform: translateY(4.4px); }
/* The middle bar goes by losing its COLOUR, not its opacity: ::before and
   ::after are its own children, so fading the element faded the two
   strokes that make the cross and the button came out empty. */
[aria-expanded="true"] .navbar__bars { background: transparent; }
[aria-expanded="true"] .navbar__bars::before { transform: rotate(45deg); }
[aria-expanded="true"] .navbar__bars::after  { transform: rotate(-45deg); }
.home__rail { display: none; }

/* The sidebar heading doubles as the way back to the gallery. */
.sideTitle {
  display: block;
  padding: 0;
  border: none;
  background: none;
  font: inherit;
  color: inherit;
  text-align: left;
  cursor: pointer;
  transition: opacity var(--hover);
  /* `font: inherit` above wipes .panel__title, so the article-title type is
     restated here rather than relying on source order. */
  font-size: 28px;
  line-height: 1.18;
  font-weight: 500;
  letter-spacing: -0.022em;
}
.sideTitle:hover { opacity: 0.68; }

/* The gallery's own title is the page title of that scene, so it takes the
   same type as the article title rather than the 19px panel heading. */
#sideList {
  flex: 1 1 auto;
  min-height: 0;
  padding-top: 10px;
  padding-bottom: var(--side-inset);
  overflow-y: auto;
  overflow-x: hidden;
  overscroll-behavior: contain;
  scrollbar-width: none;
}
#sideList::-webkit-scrollbar { display: none; }

#listTitle {
  font-size: 28px;
  line-height: 1.18;
  font-weight: 500;
  letter-spacing: -0.022em;
  margin-bottom: 18px;
}

/* ── Sticky bar: handheld only; the desktop deck has no scroll to leave. ── */
.navbar { display: none; }

/* Only on a device that is actually held. Tying it to width alone meant a
   narrow laptop window — which reflows to the single column — also got the
   bar, and on a desktop the deck has no page scroll to collapse out of. */
@media (max-width: 900px) and (pointer: coarse) {
  /* The hero's name and links, pinned once the real ones scroll away. */
  .navbar {
    position: fixed;
    inset: 0 0 auto 0;
    z-index: 60;
    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: 14px;
    padding: 10px 18px;
    padding-top: calc(10px + env(safe-area-inset-top, 0px));
    border-radius: 0;
    box-shadow: inset 0 -1px 0 rgba(255,255,255,0.14);
    transform: translateY(-101%);
    opacity: 0;
    pointer-events: none;
    transition: transform 0.34s var(--spring), opacity 0.2s linear;
  }
  .navbar.is-on { transform: none; opacity: 1; pointer-events: auto; }
  /* No menu on the deck's bar: every place the menu lists is already on a
     card in front of you there. The toggle belongs to the pages. */
  .navbar__toggle { display: none; }
  .navbar__name {
    font-size: 17px;
    font-weight: 500;
    letter-spacing: -0.015em;
    color: #fff;
  }
  .navbar__links { display: flex; gap: 16px; }
  /* Three bars, drawn rather than typed, so it sits on the same optical
     weight as the name beside it. */
  .navbar__toggle {
    flex: 0 0 auto;
    width: 34px; height: 34px;
    display: grid; place-items: center;
    margin-right: -6px;
    padding: 0;
    border: none;
    background: none;
    color: #fff;
    cursor: pointer;
  }
  .navbar__links a {
    font-size: 14px;
    color: #fff;
    opacity: var(--label-2);
    text-decoration: none;
  }
}

/* ───────────────────────── Loader ───────────────────────── */

.loader {
  position: fixed; inset: 0; z-index: 100;
  display: grid; place-items: center;
  transition: opacity 1s var(--spring), visibility 1s var(--spring);
}
.loader.is-done { opacity: 0; visibility: hidden; pointer-events: none; }

/* The loader blurs whatever scene is current rather than carrying its own
   fixed image, so it always matches the room behind it — and a new scene
   needs no second, pre-blurred copy. The overscan hides the soft edge a
   blur leaves at the element's bounds. */
.loader__bg {
  /* The overscan has to beat the blur radius, or the feathered edge of the
     filter lands inside the viewport and the sides read as unblurred. At -8%
     a phone only got ~31px of margin against a 34px blur. */
  position: absolute; inset: -72px;
  background: var(--bg, url('assets/bg-afternoon.png')) center / cover;
  filter: blur(34px) saturate(1.06);
  transform: scale(1.06);
}
.loader__scrim { position: absolute; inset: 0; background: rgba(0,0,0,0.28); }

.loader__inner { position: relative; width: min(74vw, 720px); }

.hw {
  width: 100%; height: auto; display: block;
  overflow: visible;
  color: #fff;
}

/* One path per pen-down stroke: the dash pattern resets at the start of
   every subpath, so a single path would paint all the short strokes at once. */
.hw path {
  fill: none;
  stroke: currentColor;
  stroke-dasharray: var(--len) var(--len);
  stroke-dashoffset: var(--len);
  opacity: 0;
}

/* A touch screen reports a hover that never ends: the finger that scrolled
   past a row leaves it lit, and lit it stays until something else is
   touched. Hover belongs to a pointer that can leave, so on a touch screen
   every one of these is turned off. The row being read is marked with
   aria-current rather than hover, so it keeps its fill. */
@media (hover: none) {
  .row:not([aria-current="true"]):hover { background: transparent; }
  .row:not([aria-current="true"]):hover .row__text { opacity: var(--label-2); }
  .row:not([aria-current="true"]):hover .row__chev { opacity: var(--label-3); }
  .row:not([aria-current="true"]):hover .row__cover { filter: grayscale(1); }
  .tile:hover .tile__art { transform: none; background: var(--fill-2); }
  .tile:hover .tile__title { color: rgb(255 255 255 / var(--label-2)); }
  .poster:hover, .shotcell:hover { transform: none; }
  /* Same for the controls beside the card. The back button kept its lit
     fill after any tap that landed near it. */
  .back:hover { background:
    linear-gradient(180deg,
      rgba(255,255,255,0.17) 0%,
      rgba(255,255,255,0.08) 32%,
      rgba(255,255,255,0.05) 100%),
    rgba(126, 130, 138, 0.24); }
  .photo__nav:hover { background:
    linear-gradient(180deg,
      rgba(255,255,255,0.17) 0%,
      rgba(255,255,255,0.08) 32%,
      rgba(255,255,255,0.05) 100%),
    rgba(126, 130, 138, 0.30); }
  .seeall:hover { opacity: var(--label-3); }
  .sideTitle:hover { opacity: 1; }
  .prose a:hover { text-decoration-color: rgba(255,255,255,0.55); }
}

/* ── The handheld menu the top bar opens ──────────────────────────────────
   A sheet under the bar rather than a full-screen takeover: the page stays
   visible behind it, so it reads as a drawer on the same surface. */
.menu { display: none; }

@media (max-width: 900px) {
  .menu:not([hidden]) { display: block; }
  /* The room and the page go soft behind the menu; the sheet and the
     button that opened it are the only things left sharp. */
  .menu__scrim {
    position: fixed;
    inset: 0;
    z-index: 70;
    background: rgba(0,0,0,0.22);
    -webkit-backdrop-filter: blur(20px) saturate(120%);
            backdrop-filter: blur(20px) saturate(120%);
    animation: paneIn 0.2s linear;
  }
  /* Lifted clear of the scrim so the toggle stays crisp and keeps working
     while the menu is open. */
  body.is-menu .navbar { z-index: 72; }
  body.is-menu #railMenu,
  body.is-menu #homeMenu { position: relative; z-index: 72; }

  .menu__sheet {
    position: fixed;
    z-index: 71;
    left: 12px; right: 12px;
    /* Replaced on open with a value measured from the toggle; this is the
       fallback for the deck's bar. */
    top: calc(62px + env(safe-area-inset-top, 0px));
    max-height: calc(100dvh - 90px - env(safe-area-inset-top, 0px));
    overflow-y: auto;
    border-radius: 22px;
    /* The same air under the last row as over the first. */
    padding: 10px 18px;
    animation: inFlat 0.3s var(--spring) backwards;
  }
  .menu__list { list-style: none; margin: 0; padding: 0; }
  .menu__list .row { padding: 10px 9px; }
  .menu__list .row__text { font-size: 17px; }
  /* The break between where to go and how to reach him. */
  .menu__rule {
    height: 1px;
    margin: 8px 9px;
    background: var(--separator);
  }

  /* One per line, like everything above the rule. No underline — inside a
     menu everything is a link, so marking them as such says nothing. */
  .menu__socials { display: block; }
  .menu__social {
    display: block;
    width: 100%;
    padding: 10px 9px;
    border: none;
    border-radius: var(--r-cell);
    background: none;
    color: rgb(255 255 255 / var(--label-2));
    font: inherit;
    font-size: 17px;
    letter-spacing: -0.012em;
    text-align: left;
    text-decoration: none;
    cursor: pointer;
    transition: background var(--hover);
  }
  .menu__social:active { background: var(--fill-1); }
}

/* ───────────────────────── Mobile ─────────────────────────
   Below the deck's minimum the three-up layout stops working, so the
   scene becomes a single scrolling column of full-width cards.        */

@media (max-width: 900px) {
  /* Let the document itself scroll. Keeping body at height:100% with its own
     overflow makes it a scroll container that the page can outgrow silently. */
  /* clip, not hidden, and on one axis only: a card dragged a screen-width
     to the side makes the DOCUMENT that much wider, and the phone then pans
     to follow it — which is what carried the room and the button row along
     with the swipe. clip trims that overflow without making the page a
     scroll container, which `hidden` would, and a dead scroll container
     here is what swallowed the page's own scrolling before. overflow-y
     stays visible: clip on one axis is the one pairing the spec keeps. */
  html, body {
    height: auto;
    min-height: 100%;
    overflow-x: clip;
    overflow-y: visible;
  }

  /* The room stays sharp. It was blurred here so the strips of it beside
     the cards would read as the same material as the frosted glass, but
     that softened the whole photograph to get it — the glass can do its own
     blurring. Only the parallax transform goes, since there is no pointer
     to drift against at this width. */
  /* The ceiling light is a dark bar across the top of the room, and on a
     phone the button row sits right over it — the glass sampled it and the
     buttons went dark. Measured off the image: on a 375x812 screen the bar
     runs from y66 to y90, which the old scale(1.06) lifted to y46-y71,
     straight through the buttons. This is the least that clears it — the
     bar lands at y-14 — rather than a crop that throws the room away. The
     scale is there to keep the bottom edge covered while it moves. */
  /* will-change is dropped with it: there is no parallax at this width, so
     the promotion buys nothing, and a card sampling its backdrop against a
     separately composited fixed layer is the likeliest cause of the cards
     flashing pale as they scroll under the top edge. */
  .environment__image { transform: scale(1.2) translateY(-4%); will-change: auto; }

  .stage { height: auto; min-height: 100%; }

  .scene {
    position: static;
    display: block;
    padding: 14px 12px 32px;
    transform-style: flat;
  }
}

@media (max-width: 900px) {
  .carousel { --per: 2; }   /* two across on a handheld */
  .carousel--posters { --per: 3; }   /* posters are half as wide as a tile */
  .carousel--shots   { --per: 1.6; }  /* a photo needs the width on a phone */
  .gallery { gap: 22px; }

  .deckwrap { max-width: none; gap: 10px; }
  .deck { display: flex; flex-direction: column; gap: 12px; }
  .status { display: none; }   /* date and weather are desktop-only */

  /* No tilt and no parallax on a handheld — the card already faces you */
  .panel,
  .panel--left,
  .panel--right,
  .panel--center {
    transform: none !important;
    height: auto;
    width: 100%;
    max-width: 100%;
    animation-name: inFlat;
    border-radius: 22px;
  }
  .panel--left { order: -1; }   /* identity first on a handheld */
  /* Back out of a list lands here rather than at the top of the page; the
     margin is the gap it leaves above the card. See goBackSection. */
  .panel--right { scroll-margin-top: 14px; }

  /* Two lines of larger writing rather than one thin one — see
     wrapLoaderLine in app.js, which rebuilds the viewBox to match. */
  .loader__inner { width: min(84vw, 520px); }

  /* The article's own title takes the size a section heading has inside a
     card — at 28px it was the biggest thing on a phone screen by some way.
     The lines under it close up to it, a sentence's distance rather than a
     paragraph's. */
  #detailTitle {
    font-size: 22px;
    line-height: 1.24;
    letter-spacing: -0.012em;
    margin-bottom: 1px;
  }
  /* "Last updated 11 Oct 2026" and "Sep 2026 – Present" are the same kind
     of line — a date under a title — so they are set the same way. On a
     page that has only one of the two, nothing about the opening changes. */
  .article__updated {
    font-size: 16px;
    line-height: 1.52;
    letter-spacing: -0.01em;
    color: rgb(255 255 255 / var(--label-2));
    opacity: 1;
    margin-bottom: 1px;
  }
  /* The same break the list puts between its page title and its first
     group heading, so an article opens on the same rhythm as a list. */
  .article__meta { margin-bottom: 48px; }

  /* Over the toggle that opened the menu, inside the overlay. No entrance
     animation: .back carries one with a delay and fill-mode backwards,
     which holds it invisible until the delay elapses — on a button that
     appears on a tap that reads as a button that did not appear. */
  .menu__close { position: fixed; z-index: 72; animation: none; }

  /* The glass is lit from above by a gradient whose stops are percentages.
     On the deck's short cards that reads as a sheen along the top edge; on
     a phone, where a card can run two thousand pixels, the same 32% stop
     put the bright half of it over everything near the top. Fixed stops
     keep the highlight an edge at any height. */
  .glass {
    background:
      linear-gradient(180deg,
        rgba(255,255,255,0.15) 0px,
        rgba(255,255,255,0.07) 90px,
        rgba(255,255,255,0.05) 220px),
      rgba(126, 130, 138, 0.24);
  }

  /* Two cards, not three. The vision has moved into the portrait's card and
     Personal to the foot of the index, so the middle card is left holding
     nothing — see reflowHome() in app.js. */
  .panel--center { display: none; }

  /* No rule under the portrait: the picture fades out into the card on its
     own, and a line drawn across that fade reads as a seam rather than a
     section break. The air goes with it — what is left is the portrait's
     own bottom, which is generous already. */
  .visionblock { margin-top: 2px; }
  /* Its first heading no longer opens a card of its own, so the rule above
     it would be the second line in a row. */
  .visionblock .panel__title:first-child { margin-top: 0; }

  /* Landed at the foot of the index, Personal is one more block in that
     column and takes the same break the blocks above it use. Its own 25px
     was reckoned against the portrait, which carries slack of its own. */
  .panel--right .aboutme { margin-top: 28px; padding-top: 28px; }

  /* The PAGE is the scroller here, so no card may be a scroll container of
     its own. An overflow box that has nothing to scroll still captures the
     gesture, and the `overscroll-behavior: contain` it inherits then stops
     the page from taking it over — which is what made the deck feel stuck
     on a phone. Both are cleared. */
  .panel__scroll {
    height: auto;
    overflow: visible;
    overscroll-behavior: auto;
    padding: 20px 18px;
    display: block;
  }
  .panel--left .hero__portrait { height: min(46vh, 380px); margin-top: 14px; }

  /* One text column on all three cards. The index card normally carries its
     18px inset on the HEADINGS rather than on the scroller, so a row's fill
     can run wider than the text; that put its headings 14px deeper than the
     other two cards, which is the gap he could see. Here it uses the same
     scheme the identity card does: the inset sits on the scroller, and the
     rows bleed back out past it by the same 14px. Headings, dividers and
     row text then all start on one x. */
  .panel__scroll--mid { padding-inline: 18px; }
  .panel__scroll--mid .block__head,
  .panel__scroll--mid .block__label,
  .panel__scroll--mid .tabs { padding-inline: 0; }
  .panel__scroll--mid .rows { margin-inline: -14px; }

  /* The grouped list is the sidebar's list wearing the gallery's box. The
     gallery is a flex column with a 26px gap between shelves, and here that
     gap fell between a heading and its OWN rows — more air under a heading
     than above it, the rhythm upside down. Block layout hands the spacing
     back to the margins, and the margins are the sidebar's, so a group
     reads the same on a phone as it does on the desktop sidebar. */
  #gallery { display: block; }
  #gallery .block__label { margin: 36px 0 13px; }
  /* The page title is a bigger break than one group heading from the next,
     so the first heading gets more air than the 36px between groups, not
     less — it was sitting on 2px. */
  #gallery > .block__label:first-child { margin-top: 30px; }
  /* A row's CONTENT starts on the heading's x and its fill reaches past it
     on both sides, the way the desktop sidebar does it. The 9px is the
     row's own inset; pulling the list out by the same amount lands the
     cover exactly under the P of "Product". */
  #gallery .rows { margin-inline: -9px; }

  .scene--stack { display: block; padding: 14px 12px 32px; }

  /* The deck's own row: the name where an article puts its section, and
     the same menu button. It scrolls with the page rather than sticking,
     so nothing is ever covered by it. */
  /* The scene carries the top inset, exactly as .scene--stack does for an
     article, so the deck's row and the article's row start on the same
     line. Padding on the rail itself put it 14px lower than the other. */
  #home { padding-top: 14px; }
  /* No inset of its own: the scene already supplies the 12px, and adding
     another put the deck's row 26px narrower than the article's. */
  .home__rail {
    display: flex;
    align-items: center;
    gap: 8px;
    padding: 0;
  }
  /* The name is a heading, not a button label: same type as Vision and the
     section headings inside the cards, and ranged left like them. */
  .home__rail .back--text {
    justify-content: flex-start;
    /* 18px, not the button's usual 16: it lands the name on the same x as
       the headings inside the card below it. */
    padding-left: 18px;
    font-size: 19px;
    font-weight: 500;
    letter-spacing: -0.015em;
  }
  /* The name lives in the row now, so the card starts at the portrait and
     the air that was above it goes. */
  #home .hero { display: none; }
  #home .hero__portrait { margin-top: 0; }
  /* The sticky bar is retired at this width: the row above replaces it. */
  .navbar { display: none; }

  /* Back, the section you are in, and the menu — three separate glass
     buttons on one row. The section opens its full list, which is what the
     grid button does on a wide screen, so that one goes. */
  .stackrow__rail {
    flex-direction: row;
    align-items: center;
    gap: 8px;
    width: 100%;
  }
  #sideAll { display: none; }
  .back--text, .back--menu { display: inline-flex; }
  /* The chevron alone, the same square as the menu — so the section button
     between them is centred in what is left rather than pushed off by a
     word on one side only. */
  .back {
    width: 42px;
    padding: 0;
    gap: 0;
    justify-content: center;
    /* The card's own corner, so the row above it is cut from the same
       shape. On a 42px button that is all but a pill — half its height is
       21px — which is the honest consequence of matching the radius. */
    border-radius: 22px;
  }
  /* The word, not the icon: the hamburger is a span too, and hiding every
     span in the button left an empty glass square. */
  .back:not(.back--text) > span:not(.navbar__bars) { display: none; }
  /* The section button takes everything left between the two fixed ones,
     so the row reads as one control strip rather than three islands with a
     hole in the middle. */
  .back--text {
    flex: 1 1 auto;
    width: auto;
    min-width: 0;
    padding: 0 16px;
    justify-content: center;
  }
  .back--text > span {
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
  }
  .stackrow { display: block; max-width: none; }
  /* Stacked above the card at this width. The margin belongs to the pair,
     not to each button, or the rail's own gap is paid twice between them.
     The photo scene has no rail — one button, no section, no menu — so it
     carries the gap itself; without this its card ran straight into the
     back button. */
  .stackrow__rail { margin-bottom: 12px; }
  .back { margin-bottom: 0; }
  #photo .back { margin-bottom: 12px; }

  /* The fixed-rectangle treatment is desktop-only: here the page itself
     scrolls, so the card must grow with its content, not clip it. */
  /* overflow-x: clip, not hidden: the photo rails bleed out to the card's
     edge by design, and with the card left visible they carried on past it
     into the room. clip trims them to the card's rounded shape WITHOUT
     making it a scroll container — hidden would, and a dead scroll
     container here is what swallowed the page's scroll before. */
  .sheet {
    max-width: none;
    border-radius: 22px;
    height: auto;
    /* Always down to the foot of the screen, even when the list inside it
       is short — a card that stopped halfway left the room showing under
       it and read as a panel rather than a page. It still grows past this
       when the content is longer. */
    min-height: calc(100vh - 100px);
    min-height: calc(100dvh - 100px);
    overflow-x: clip;
    overflow-y: visible;
  }
  .sheet--split > *, .sheet__body { min-height: auto; display: block; }
  /* The sidebar is a separate step on mobile — see the list view */
  .sheet--split { grid-template-columns: 1fr; }
  .sheet--split .sheet__side { display: none; }
  /* The photo card is the exception to "a card grows with its content": its
     picture is positioned absolutely inside the frame, so the frame has
     nothing in flow to take height from and collapses to an empty bar — the
     viewer opened on nothing. It keeps an explicit height, as it has on
     desktop, sized to the screen less the back button and the padding.
     dvh so the phone's own chrome does not crop it. */
  .sheet--photo {
    height: calc(100vh - 110px);
    height: calc(100dvh - 110px);
    min-height: 300px;
    overflow: hidden;
  }

  /* The sheet's inner scroller is a desktop device. Here it must grow so the
     page can scroll the whole article, not clip it to one screen. */
  .sheet .panel__scroll {
    height: auto; max-width: none; overflow: visible; overscroll-behavior: auto;
  }

  /* The neighbouring page, while a swipe is running: a real card of its
     own beside the one you are on, not a second column inside it. Top and
     height are measured from the live card in openPeer, so the two are the
     same box at the same height and the pair reads as one filmstrip. --off
     holds it a screen-width to the left or right; --px is the finger, and
     both cards get the same value, so they travel together.

     The transform is on the glass itself, never on an ancestor of it: an
     ancestor with a transform opens a new backdrop root and the blur would
     collapse for the length of the drag. */
  .stackrow { position: relative; }
}

/* ───────────────────────── Accessibility ───────────────────────── */

@media (prefers-reduced-transparency: reduce) {
  .glass, .back {
    -webkit-backdrop-filter: none;
            backdrop-filter: none;
    background: rgba(38, 40, 44, 0.94);
  }
}

@media (prefers-contrast: more) {
  .glass { background: rgba(32, 34, 38, 0.90); }
  :root { --label-2: 0.86; --label-3: 0.66; }
}

@media (forced-colors: active) {
  .glass, .back { outline: 1px solid CanvasText; }
}

@media (prefers-reduced-motion: reduce) {
  *, *::before, *::after {
    animation-duration: 0.01ms !important;
    animation-iteration-count: 1 !important;
    transition-duration: 0.01ms !important;
  }
  .hw path { stroke-dashoffset: 0 !important; opacity: 1 !important; }
  .panel { transform: none !important; }
}
