/* ==========================================================================
   View composition — landing (title row + quadrants) and the section stub
   ========================================================================== */

.view {
  display: flex;
  flex-direction: column;
  height: 100%;
}

.view[hidden] {
  display: none;
}

/* ---- title row ---------------------------------------------------- */

.title-row {
  flex: 0 0 auto;
  display: flex;
  align-items: center;
  justify-content: center;
  min-height: 5.5rem;
  padding: var(--gap-md);
  border-bottom: var(--border-hair);
}

.title-row__wordmark {
  margin: 0;
  font-family: var(--font-display);
  /* Sixtyfour Convergence runs much wider and taller per glyph than the mono
     face, so it needs its own size, tracking and leading rather than the
     label defaults. */
  /* Scales with the viewport instead of sitting at a fixed rem: the glyphs
     are ~1em wide each, so 24 characters at a fixed size overflow the frame
     on a narrow window. 3.7vw keeps a hair of margin at every width; the
     clamp ceiling stops it ballooning on an ultrawide display. */
  font-size: clamp(1.1rem, 3.7vw, 3rem);
  font-weight: 400;
  letter-spacing: 0;
  line-height: 1.1;
  width: 100%;
  text-align: center;
  font-variation-settings: "BLED" var(--display-bleed), "SCAN" 0;
  font-palette: var(--display-palette);
  text-shadow: var(--glow-text);
}

/* ---- display headings ------------------------------------------------
   The case-study and dossier h2s share the wordmark's pixel face. Body copy,
   row tags, subtitles and the subfolder divider labels deliberately stay on
   --font-mono: this face has no italic, no weight axis, and turns to mush
   below ~0.9rem.

   Each heading keeps its own font-size at its own rule below, stepped down
   from the mono value it used to carry — the display face runs about 1em per
   glyph against the mono face's ~0.6em, so an unchanged size overflows the
   panel on the longer titles. letter-spacing goes to 0 for the same reason;
   the face is already generously spaced and --tracking-label only made it
   wider. */
.gd-case-study__title,
.cs2__title,
.pres-case-study__title,
.m3d-case-study__title,
.about-dossier__name {
  font-family: var(--font-display);
  letter-spacing: 0;
  line-height: 1.2;
  font-variation-settings: "BLED" var(--display-bleed), "SCAN" 0;
  font-palette: var(--display-palette);
}

/* Project titles centre over their panel. .about-dossier__name is left out
   on purpose — it's a dossier header with the role subtitle and body copy
   running left-aligned directly beneath it. */
.gd-case-study__title,
.cs2__title,
.pres-case-study__title,
.m3d-case-study__title {
  text-align: center;
}

/* ---- quadrant grid -------------------------------------------------- */

.quadrants {
  flex: 1 1 auto;
  display: grid;
  grid-template-columns: 1fr 1fr;
  grid-template-rows: 1fr 1fr;
  gap: var(--gap-sm);
  padding: var(--gap-sm);
  min-height: 0;
}

@media (max-width: 600px) {
  .quadrants {
    grid-template-columns: 1fr;
    grid-template-rows: repeat(4, 1fr);
  }
}

.quadrant {
  --bracket-color: var(--phosphor-dim);
  position: relative;
  display: flex;
  flex-direction: column;
  justify-content: flex-end;
  align-items: flex-start;
  padding: var(--gap-md);
  background: var(--bg-panel);
  border: var(--border-hair);
  color: var(--phosphor);
  font-family: var(--font-mono);
  text-align: left;
  cursor: pointer;
  overflow: hidden;
  transition: color 0.15s ease, border-color 0.15s ease;
}

/* Each quadrant previews the color of the section it leads to, regardless
   of the current page theme (landing is always white) — raw hue tokens,
   not the themed --phosphor/--bg-panel, which only reflect whatever
   section is currently active. Read by main.js as the dither dot color. */
.quadrant[data-section="graphic-design"] {
  --quadrant-tint: var(--phosphor-lime);
}

.quadrant[data-section="presentations"] {
  --quadrant-tint: var(--phosphor-amber);
}

.quadrant[data-section="motion-3d"] {
  --quadrant-tint: var(--phosphor-purple);
}

.quadrant[data-section="about"] {
  --quadrant-tint: var(--phosphor-white);
}

/* Hidden until hover — a genuine two-color dithered bitmap of the section's
   photo (see js/dither.js renderDitheredPhoto), dot size mapped to each
   cell's luminance and drawn in the section's own phosphor color. The
   canvas fills the box; there's no separate gradient/grayscale layer
   underneath because the dithered image already carries its own color and
   tone — it's just dots on the button's existing dark background. */
.quadrant__photo {
  position: absolute;
  inset: 0;
  overflow: hidden;
  opacity: 0;
  pointer-events: none;
  transition: opacity 0.3s ease;
}

.quadrant:hover .quadrant__photo,
.quadrant:focus-visible .quadrant__photo {
  opacity: 1;
}


.quadrant__photo-canvas {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  display: block;
}

/* Guarantees the label/number stay legible no matter how bright the photo
   is at that crop — neutral black, not tinted, so it works over any hue. */
.quadrant__photo-scrim {
  position: absolute;
  inset: 0;
  background: linear-gradient(to top, rgba(0, 0, 0, 0.8) 0%, transparent 60%);
}

/* Cosmetic, so it's sized to match the label and tucked in the corner —
   the label is the real information here, the number just decorates it. */
.quadrant__number {
  position: absolute;
  right: 0;
  bottom: 0;
  padding: var(--gap-md);
  font-size: 1.5rem;
  color: var(--phosphor-mut);
  text-shadow: var(--glow-text);
}

.quadrant__name {
  position: relative;
  font-size: 1.5rem;
  letter-spacing: var(--tracking-label);
  text-transform: uppercase;
  text-shadow: var(--glow-text);
}

/* Bracket corners on a dedicated overlay (last child) rather than on the
   button's own background, so they stay visible on top of the photo. */
.quadrant__corners {
  position: absolute;
  inset: 0;
  pointer-events: none;
}

@keyframes quadrant-glitch {
  0%   { opacity: 1; transform: translate(0, 0); }
  20%  { opacity: 0.65; transform: translate(-1px, 1px); }
  40%  { opacity: 1; transform: translate(1px, -1px); }
  60%  { opacity: 0.75; transform: translate(-1px, 0); }
  80%  { opacity: 1; transform: translate(1px, 0); }
  100% { opacity: 1; transform: translate(0, 0); }
}

.quadrant:hover,
.quadrant:focus-visible {
  --bracket-color: var(--phosphor-hi);
  color: var(--phosphor-hi);
  border-color: var(--phosphor-mut);
  animation: quadrant-glitch 0.25s steps(2, end);
}

.quadrant:hover .quadrant__number,
.quadrant:focus-visible .quadrant__number {
  color: var(--phosphor-hi);
}

/* ---- section stub (full shell arrives in a later step) -------------- */

.section-header {
  flex: 0 0 auto;
  display: flex;
  align-items: baseline;
  gap: var(--gap-md);
  padding: var(--gap-sm) var(--gap-md);
  border-bottom: var(--border-hair);
}

/* Landscape-phone only (see the media block further down) — off everywhere
   else. A real control, not the drawer's own tab relocated: that tab's
   "peek" look comes from being translated along with the drawer as its
   last child, and moving it out of the drawer would break that math. This
   proxies clicks to the same open/close state instead (js/main.js). */
.header-drawer-toggle {
  display: none;
}

.home-link {
  background: none;
  border: none;
  padding: 0;
  color: var(--phosphor);
  font-family: var(--font-mono);
  font-size: 0.929rem;
  letter-spacing: var(--tracking-label);
  cursor: pointer;
  /* 2.5.8: the 44px target below only kicks in for touch/narrow. Desktop
     mouse users were left with the raw text box (~81x23), just under the
     24px AA minimum — give every pointer size a 24px floor unconditionally. */
  min-height: 24px;
  display: inline-flex;
  align-items: center;
}

.home-link:hover,
.home-link:focus-visible {
  color: var(--phosphor-hi);
}

@media (max-width: 600px), (pointer: coarse) {
  .home-link {
    min-height: 44px;
    min-width: 44px;
    display: inline-flex;
    align-items: center;
  }
}

.breadcrumb {
  font-size: 0.929rem;
  letter-spacing: var(--tracking-label);
  color: var(--phosphor-mut);
  text-transform: uppercase;
}

/* ---- graphic design: category rail + project list + case study ------ */

.gd-body {
  flex: 1 1 auto;
  display: flex;
  flex-direction: column;
  gap: var(--gap-sm);
  padding: var(--gap-sm);
  min-height: 0;
}

/* Top level: a 2x2 grid, same language as the landing quadrants. Once a
   category is picked, .is-nested collapses it into a full-width row of
   equal tabs sitting above the project list + case study. */
.gd-categories {
  display: grid;
  grid-template-columns: 1fr 1fr;
  grid-template-rows: 1fr 1fr;
  gap: var(--gap-sm);
  flex: 1 1 auto;
  min-height: 0;
}

.gd-categories.is-nested {
  display: flex;
  flex-direction: row;
  flex: 0 0 auto;
  gap: var(--gap-sm);
}

@media (max-width: 600px) {
  .gd-categories:not(.is-nested) {
    grid-template-columns: 1fr;
    grid-template-rows: repeat(4, 1fr);
  }
}

.gd-category {
  --bracket-color: var(--phosphor-dim);
  position: relative;
  display: flex;
  flex-direction: column;
  justify-content: flex-end;
  align-items: flex-start;
  padding: var(--gap-md);
  background: var(--bg-panel);
  border: var(--border-hair);
  color: var(--phosphor);
  font-family: var(--font-mono);
  text-align: left;
  cursor: pointer;
  overflow: hidden;
  transition: color 0.15s ease, border-color 0.15s ease;
}

/* Hidden until hover — same dithered-bitmap treatment as the landing
   quadrants (js/dither.js), just tinted with whatever --phosphor already
   resolves to on this page (Graphic Design's lime) rather than a per-
   section tint, since there's only one theme active here. The image is a
   random project's photo from within that category — see
   findRandomCategoryPhoto() in main.js — picked once per page load and
   reused, not re-rolled on every hover. Only shown in the 2x2 grid state;
   once collapsed into the nested tab strip there's no room for it. */
.gd-category__photo {
  position: absolute;
  inset: 0;
  overflow: hidden;
  opacity: 0;
  pointer-events: none;
  transition: opacity 0.3s ease;
}

.gd-category:hover .gd-category__photo,
.gd-category:focus-visible .gd-category__photo {
  opacity: 1;
}


.gd-category__photo-canvas {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  display: block;
}

.gd-category__photo-scrim {
  position: absolute;
  inset: 0;
  background: linear-gradient(to top, rgba(0, 0, 0, 0.8) 0%, transparent 60%);
}

.gd-categories.is-nested .gd-category__photo {
  display: none;
}

/* Dedicated last-child overlay, same reasoning as .quadrant__corners — so
   the glow paints on top of the hover photo instead of being hidden
   underneath it. */
.gd-category__corners {
  position: absolute;
  inset: 0;
  pointer-events: none;
}

/* Cosmetic, so it's sized to match the label and tucked in the corner —
   the label is the real information here, the number just decorates it. */
.gd-category__number {
  position: absolute;
  right: 0;
  bottom: 0;
  padding: var(--gap-md);
  font-size: 1.4rem;
  color: var(--phosphor-mut);
  text-shadow: var(--glow-text);
}

.gd-category__name {
  position: relative;
  font-size: 1.4rem;
  letter-spacing: var(--tracking-label);
  text-transform: uppercase;
  text-shadow: var(--glow-text);
}

.gd-category:hover,
.gd-category:focus-visible {
  animation: quadrant-glitch 0.25s steps(2, end);
}

.gd-category:hover,
.gd-category:focus-visible,
.gd-category.is-active {
  --bracket-color: var(--phosphor-hi);
  color: var(--phosphor-hi);
  border-color: var(--phosphor-mut);
}

/* nested top row: equal-width tabs, number + name inline and centered —
   undoes the grid state's corner-positioned number in favor of sitting
   inline next to the name. */
.gd-categories.is-nested .gd-category {
  flex: 1 1 0;
  flex-direction: row;
  justify-content: center;
  align-items: baseline;
  gap: var(--gap-sm);
  padding: var(--gap-sm) var(--gap-md);
  text-align: center;
}

.gd-categories.is-nested .gd-category__number {
  position: static;
  padding: 0;
  font-size: 0.857rem;
}

.gd-categories.is-nested .gd-category__name {
  font-size: 0.857rem;
}

/* list + master panel side by side, once a category is picked */
.gd-detail {
  flex: 1 1 auto;
  min-width: 0;
  min-height: 0;
  display: flex;
  gap: var(--gap-sm);
}

/* empty until a category is picked, so the grid gets the full width */
.gd-detail:empty {
  display: none;
}

/* Shell only — the rows live in .gd-projects__list, which is what actually
   scrolls. The split exists so the shell can become a sliding drawer with a
   fixed tab on mobile (see the mobile section at the end of this file). */
.gd-projects {
  flex: 0 0 240px;
  display: flex;
  flex-direction: column;
  min-height: 0;
}

.gd-projects__list {
  flex: 1 1 auto;
  min-height: 0;
  display: flex;
  flex-direction: column;
  gap: var(--gap-xs);
  overflow-y: auto;
}

/* Scannable command-menu list row — shared by any section with a flat or
   nested project list (Graphic Design, Motion & 3D). */
.project-row {
  display: flex;
  flex-wrap: wrap;
  align-items: baseline;
  justify-content: space-between;
  gap: var(--gap-xs) var(--gap-sm);
  padding: var(--gap-sm) var(--gap-md);
  background: var(--bg-panel);
  border: var(--border-hair);
  color: var(--phosphor);
  font-family: var(--font-mono);
  text-align: left;
  cursor: pointer;
  transition: color 0.15s ease, border-color 0.15s ease;
}

.project-row__label {
  display: flex;
  flex-wrap: wrap;
  flex: 1 1 auto;
  min-width: 0;
  align-items: baseline;
  gap: 2px var(--gap-sm);
}

.project-row__title {
  flex: 1 1 100%;
  min-width: 0;
  font-size: 1rem;
  letter-spacing: var(--tracking-label);
  text-transform: uppercase;
}

.project-row:hover,
.project-row:focus-visible {
  color: var(--phosphor-hi);
  border-color: var(--phosphor-mut);
}

/* currently-showing project — reversed for a targeting-lock look */
.project-row.is-active {
  background: var(--phosphor);
  border-color: var(--phosphor);
  color: #000;
}

.project-row.is-active:hover,
.project-row.is-active:focus-visible {
  color: #000;
  border-color: var(--phosphor);
}

.project-row.is-active .project-row__tag {
  border-color: rgba(0, 0, 0, 0.4);
  color: #000;
}

.gd-case-study {
  flex: 1 1 auto;
  min-height: 0;
  display: flex;
  flex-direction: column;
  align-items: flex-start;
  gap: var(--gap-sm);
  padding: var(--gap-md);
  background: var(--bg-panel);
  border: var(--border-hair);
  overflow-y: auto;
}

.gd-case-study__title {
  margin: 0;
  font-size: 1rem;
  font-weight: 400;
  letter-spacing: var(--tracking-label);
  text-transform: uppercase;
  text-shadow: var(--glow-text);
}

.gd-case-study__pending {
  margin-top: var(--gap-sm);
  color: var(--phosphor-mut);
  font-size: 0.929rem;
}

/* GALLERY tag next to a project-row title, in a nav list */
.project-row__tag {
  flex: 0 0 auto;
  padding: 1px 6px;
  border: 1px solid var(--phosphor-dim);
  color: var(--phosphor-mut);
  font-size: 0.643rem;
  letter-spacing: var(--tracking-label);
}

/* ---- case-study body: meta block, hook, prose, breakdown, media ------ */

.case-study__meta {
  display: flex;
  flex-direction: column;
  gap: 2px;
  padding: var(--gap-sm) var(--gap-md);
  border: var(--border-hair);
  background: var(--bg);
  width: 100%;
}

.meta-row {
  font-size: 0.786rem;
  letter-spacing: 0.02em;
  color: var(--phosphor-mut);
}

.meta-row__label {
  letter-spacing: var(--tracking-label);
  color: var(--phosphor);
}

/* The one decorative typographic moment on a case-study page. */
.case-study__hook {
  margin: var(--gap-sm) 0;
  max-width: 60ch;
  font-style: italic;
  font-size: 1.143rem;
  line-height: 1.5;
  color: var(--phosphor-hi);
  text-shadow: var(--glow-text);
}

.case-study__body {
  max-width: 68ch;
  color: var(--phosphor-mut);
  font-size: 0.929rem;
  line-height: 1.7;
}

.case-study__body p {
  margin: 0 0 var(--gap-md);
}

.case-study__body p:last-child {
  margin-bottom: 0;
}

.case-study__body--gallery-intro {
  margin-bottom: var(--gap-sm);
}

/* Bulleted technical breakdown (e.g. TopSail's composite layers) — styled
   distinctly from body prose so it reads as a spec, not narrative. */
.case-study__breakdown {
  list-style: none;
  margin: 0 0 var(--gap-md);
  padding: 0;
  display: flex;
  flex-direction: column;
  gap: var(--gap-sm);
}

.case-study__breakdown li {
  padding: var(--gap-xs) var(--gap-sm);
  border-left: 2px solid var(--phosphor-dim);
  background: var(--bg);
}

.breakdown__label {
  color: var(--phosphor);
  letter-spacing: var(--tracking-label);
  text-transform: uppercase;
  font-size: 0.857rem;
}

/* Pinterest-style masonry — same treatment as .gallery-grid below, just at
   the tighter 160px track width case studies have always used. Each image
   keeps its own natural aspect ratio instead of a forced crop. */
.case-study__media {
  columns: 160px 4;
  column-gap: var(--gap-xs);
  width: 100%;
}

.media-item {
  display: block;
  width: 100%;
  padding: 0;
  border: var(--border-hair);
  background: var(--bg);
  cursor: pointer;
  overflow: hidden;
  margin-bottom: var(--gap-xs);
  break-inside: avoid;
  -webkit-column-break-inside: avoid;
}

.media-item img,
.media-item canvas {
  width: 100%;
  height: auto;
  display: block;
}

.media-item:hover,
.media-item:focus-visible {
  border-color: var(--phosphor-mut);
}

/* Wraps an animated tile so its pause control is a SIBLING of the
   lightbox-opening button, never a descendant — a <button> cannot contain
   another interactive control (nesting them is invalid HTML, and a screen
   reader has no sane way to announce it). The wrap becomes the actual
   masonry column child in that case, so it — not the tile button inside it —
   owns the stacking gap and break-inside rule; the tile button's own
   margin-bottom is zeroed so the gap isn't doubled. Shared by both grids. */
.media-tile-wrap {
  position: relative;
  display: block;
  width: 100%;
  margin-bottom: var(--gap-xs);
  break-inside: avoid;
  -webkit-column-break-inside: avoid;
}

.media-tile-wrap .gallery-item,
.media-tile-wrap .media-item {
  margin-bottom: 0;
}

/* Small icon-only corner control — same bottom-right convention as
   .video-toggle, scaled down for a masonry tile instead of a full stage.
   Shared with the lightbox's own toggle (.lightbox__toggle adds position). */
.image-toggle {
  position: absolute;
  right: var(--gap-xs);
  bottom: var(--gap-xs);
  z-index: 1;
  min-width: 1.7rem;
  min-height: 1.7rem;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  padding: 0;
  background: var(--bg-panel);
  border: var(--border-hair);
  color: var(--phosphor-mut);
  font-family: var(--font-mono);
  font-size: 0.7rem;
  cursor: pointer;
}

.image-toggle:hover,
.image-toggle:focus-visible {
  color: var(--phosphor-hi);
}

@media (max-width: 600px), (pointer: coarse) {
  .image-toggle {
    min-width: 44px;
    min-height: 44px;
  }
}

.media-empty {
  padding: var(--gap-md);
  border: var(--border-hair);
  background: var(--bg);
  color: var(--phosphor-mut);
  font-size: 0.786rem;
  letter-spacing: var(--tracking-label);
  text-align: center;
  width: 100%;
}

.case-study__video {
  width: 100%;
}

.case-study__video video {
  display: block;
  width: 100%;
  border: var(--border-hair);
  background: #000;
}

/* Only for the YouTube path (facade + injected iframe) — neither has a
   <video> element's intrinsic size, so they need an explicit box. A
   self-hosted <video> keeps sizing itself the way it always has, portrait
   pieces included, so this never applies there.
   height comes from the stage's available space (not width), with width
   derived from the ratio and capped at 100% — that's what lets the frame
   shrink as the copy panel expands instead of holding its width-derived
   height and overflowing on top of it. min-height stops it going smaller
   than legible; .m3d-case-study__stage clips it if that floor doesn't fit. */
.case-study__video--embed {
  aspect-ratio: 16 / 9;
  width: auto;
  height: 100%;
  max-width: 100%;
  min-height: 160px;
}

.case-study__video--embed iframe {
  display: block;
  width: 100%;
  height: 100%;
  border: var(--border-hair);
  background: #000;
}

/* Self-hosted ambient <video> (e.g. the DeployX looping header) has its own
   intrinsic ratio, so it doesn't need aspect-ratio forced on it the way the
   YouTube embed above does — but in the Motion & 3D stage it was still only
   ever constrained by width, so it had the same overflow-onto-the-copy-panel
   bug once the copy hover shrank the stage below the video's natural
   width-derived height. height:100% + object-fit:contain lets it shrink to
   the stage the same way, without cropping or distorting the frame.
   Scoped to the m3d stage only — the graphic-design gallery's
   .case-study__video sits in a masonry column and should keep sizing itself
   off its own aspect ratio there. */
.m3d-case-study__stage .case-study__video {
  height: 100%;
  min-height: 160px;
}

.m3d-case-study__stage .case-study__video video {
  height: 100%;
  object-fit: contain;
}

.case-study__nav {
  display: flex;
  justify-content: space-between;
  gap: var(--gap-sm);
  width: 100%;
  margin-top: var(--gap-sm);
  padding-top: var(--gap-sm);
  border-top: var(--border-hair);
}

.case-study__nav-btn {
  flex: 1 1 0;
  min-width: 0;
  background: none;
  border: var(--border-hair);
  color: var(--phosphor-mut);
  font-family: var(--font-mono);
  font-size: 0.786rem;
  letter-spacing: var(--tracking-label);
  text-transform: uppercase;
  padding: var(--gap-xs) var(--gap-sm);
  cursor: pointer;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
  transition: color 0.15s ease, border-color 0.15s ease;
}

.case-study__nav-btn:hover,
.case-study__nav-btn:focus-visible {
  color: var(--phosphor-hi);
  border-color: var(--phosphor-mut);
}

@media (max-width: 600px), (pointer: coarse) {
  .case-study__nav-btn {
    min-height: 44px;
  }
}

/* ---- galleries -------------------------------------------------------- */

/* Pinterest-style masonry: CSS columns let each item keep its own natural
   aspect ratio (no forced crop) and stack top-to-bottom per column, unlike
   grid's row-locked tracks. column-gap handles horizontal spacing; vertical
   spacing between stacked items has to come from the item's own margin,
   since multicol has no equivalent for that axis. */
.gallery-grid {
  columns: 200px 4;
  column-gap: var(--gap-xs);
  width: 100%;
}

.gallery-item {
  display: block;
  width: 100%;
  padding: 0;
  border: var(--border-hair);
  background: var(--bg);
  cursor: pointer;
  overflow: hidden;
  margin-bottom: var(--gap-xs);
  break-inside: avoid;
  -webkit-column-break-inside: avoid;
}

.gallery-item img,
.gallery-item canvas {
  width: 100%;
  height: auto;
  display: block;
}

.gallery-item:hover,
.gallery-item:focus-visible {
  border-color: var(--phosphor-mut);
}

/* Scroll reveal — a tile stays dark until it scrolls into view, then blinks
   on like a CRT segment warming up. This is what lets galleries be unbounded
   (see MAX_GALLERY_IMAGES in js/assets.js): a 40-tile wall only lights the
   part you're actually looking at. .will-reveal is added by JS, so a
   no-JS/no-observer visitor still sees every tile at full opacity. */
.will-reveal {
  opacity: 0;
}

/* fill-mode is `backwards`, NOT `both`, and that is load-bearing. `both`
   retains the final keyframe, and a keyframe list containing `filter`
   resolves its end state to the identity filter (brightness(1) saturate(1))
   rather than to `none` — which leaves a filter on every tile, and a filter
   creates a stacking context. That promoted all 40 tiles into the positioned
   paint layer above the category bar, so clicking a category button opened
   whichever gallery image sat behind it. `backwards` applies the first
   keyframe during the stagger delay and then hands the element back to its
   own styles, leaving no filter behind. */
.will-reveal.is-revealed {
  opacity: 1;
  animation: tile-blink-in 0.34s steps(1, end) var(--reveal-delay, 0ms) backwards;
}

/* steps(1) on the animation plus hard opacity jumps is the whole trick — a
   smooth fade reads as a web page loading, a hard on/off/on reads as
   hardware. The brightness pop on the middle frame is the phosphor surge. */
@keyframes tile-blink-in {
  0%   { opacity: 0; }
  30%  { opacity: 1; filter: brightness(2.4) saturate(0.4); }
  45%  { opacity: 0.15; }
  60%  { opacity: 1; filter: brightness(1.5) saturate(0.7); }
  75%  { opacity: 0.5; }
  100% { opacity: 1; filter: none; }
}

/* Reduced motion still gets the reveal, just without the strobing — JS also
   short-circuits and marks every tile revealed up front, so this is the
   belt-and-braces case where the class was applied before that check. */
@media (prefers-reduced-motion: reduce) {
  .will-reveal {
    opacity: 1;
  }

  .will-reveal.is-revealed {
    animation: none;
  }
}

/* Sectioned galleries: loose files render as a plain grid first, then one
   titled block per subfolder. Each block carries its own masonry grid so the
   heading spans the full width — a heading inside a CSS-columns grid would
   just flow into a column. */
.media-sections {
  display: flex;
  flex-direction: column;
  gap: var(--gap-sm);
  width: 100%;
}

.media-section {
  display: flex;
  flex-direction: column;
  gap: var(--gap-xs);
}

/* Folder name, verbatim — these are Mike's own folder names, so they're set
   as a label rather than being re-cased into something he didn't write. */
.media-section__title {
  margin: 0;
  padding-top: var(--gap-xs);
  border-top: var(--border-hair);
  color: var(--phosphor-mut);
  font-size: 0.786rem;
  font-weight: 400;
  letter-spacing: var(--tracking-label);
  text-transform: uppercase;
}

.gallery-empty {
  padding: var(--gap-md) var(--gap-md) 0;
  color: var(--phosphor-mut);
  font-size: 0.786rem;
  letter-spacing: var(--tracking-label);
  text-align: center;
  width: 100%;
}

/* ---- lightbox ----------------------------------------------------------- */

.lightbox {
  position: fixed;
  inset: 0;
  z-index: 1000;
  display: flex;
  align-items: center;
  justify-content: center;
  background: rgba(0, 0, 0, 0.92);
}

.lightbox[hidden] {
  display: none;
}

.lightbox__img {
  max-width: 88vw;
  max-height: 88vh;
  object-fit: contain;
  border: var(--border-hair);
}

.lightbox__close,
.lightbox__prev,
.lightbox__next {
  position: absolute;
  background: none;
  border: var(--border-hair);
  color: var(--phosphor);
  font-family: var(--font-mono);
  font-size: 1.2rem;
  padding: var(--gap-xs) var(--gap-sm);
  cursor: pointer;
}

.lightbox__close:hover,
.lightbox__prev:hover,
.lightbox__next:hover {
  color: var(--phosphor-hi);
  border-color: var(--phosphor-mut);
}

@media (max-width: 600px), (pointer: coarse) {
  .lightbox__close,
  .lightbox__prev,
  .lightbox__next {
    min-height: 44px;
    min-width: 44px;
    display: inline-flex;
    align-items: center;
    justify-content: center;
  }
}

.lightbox__close {
  top: var(--gap-md);
  right: var(--gap-md);
}

.lightbox__prev {
  left: var(--gap-md);
  top: 50%;
  transform: translateY(-50%);
}

.lightbox__next {
  right: var(--gap-md);
  top: 50%;
  transform: translateY(-50%);
}

/* Bottom-center — clear of close (top-right) and prev/next (sides). Reuses
   the shared .image-toggle icon-button look; this rule only places it. */
.lightbox__toggle {
  left: 50%;
  right: auto; /* .image-toggle sets right; unset it or left+right both
                   apply and the translateX(-50%) centering goes wrong. */
  bottom: var(--gap-md);
  transform: translateX(-50%);
}

/* ---- layout prototype: gallery-forward + pull-tab copy drawer -------- */
/* Opt-in per project via `layoutPreview: "pull-tab"` — see main.js
   renderProjectPanel(). Overrides the base .gd-case-study padding/overflow
   it also carries as a class, since it manages its own scroll regions. */

.cs2 {
  /* Single source of truth for the tab's width, shared by the gallery's
     reserved-space padding below and the tab/drawer rules further down. */
  --cs2-tab-w: 38px;
  padding: 0;
  align-items: stretch;
  overflow: hidden;
  position: relative;
}

@media (max-width: 600px), (pointer: coarse) {
  .cs2 {
    --cs2-tab-w: 44px;
  }
}

.cs2__header {
  flex: 0 0 auto;
  padding: var(--gap-sm) var(--gap-md);
  border-bottom: var(--border-hair);
}

.cs2__title {
  margin: 0;
  font-size: 0.9rem;
  font-weight: 400;
  letter-spacing: var(--tracking-label);
  text-transform: uppercase;
  text-shadow: var(--glow-text);
}

/* Right padding permanently reserves the tab's own footprint — the same
   amount whether the drawer is open or closed — so the gallery's width
   never changes when it's toggled, and the tab (which always peeks at
   least --cs2-tab-w out from the right edge) never sits on top of a
   thumbnail, the video, or the gallery's own padding. */
.cs2__gallery {
  flex: 1 1 auto;
  min-height: 0;
  overflow-y: auto;
  padding: var(--gap-sm) calc(var(--gap-sm) + var(--cs2-tab-w)) var(--gap-sm) var(--gap-sm);
  display: flex;
  flex-direction: column;
  gap: var(--gap-sm);
}

/* ---- multi-video grid ------------------------------------------------
   A Motion & 3D project that is several films rather than one. Unlike
   .case-study__video--embed, which is height-driven so a single player
   fits a fixed stage, these are width-driven: the grid scrolls, so each
   tile just takes its column and keeps 16:9. */
.video-grid {
  display: grid;
  /* 460px floor rather than a smaller one: these are films, and four
     across at ~340px each is a contact sheet, not something anyone will
     watch. At typical widths this lands 2-3 up. */
  grid-template-columns: repeat(auto-fit, minmax(460px, 1fr));
  gap: var(--gap-sm);
  width: 100%;
  align-content: start;
  padding: var(--gap-sm);
}

.video-grid__item {
  --bracket-color: var(--phosphor-dim);
  position: relative;
  aspect-ratio: 16 / 9;
  background: #000;
  border: var(--border-hair);
}

.video-grid__item iframe {
  position: absolute;
  inset: 0;
  display: block;
  width: 100%;
  height: 100%;
  border: 0;
}

@media (max-width: 600px) {
  .video-grid {
    grid-template-columns: 1fr;
  }
}

/* ---- archived site embed ---------------------------------------------
   A self-hosted mirror of a shipped website (see
   scripts/mirror-steerbridge.js). Deliberately does NOT reuse the
   16/9 sizing that .case-study__video--embed and .pres-deck share — a
   website page is tall and scrolls.

   The sizing problem worth understanding: this panel is roughly 570px
   wide, and the mirrored site is responsive. An iframe at the panel's
   own width would trip the site's mobile breakpoints and render the
   phone layout. So JS gives the iframe a real desktop viewport
   (data.js's siteEmbed.viewport) and scales it down with a transform,
   sizing this wrapper to the scaled result. --mirror-scale is set from
   hydrateSiteSlot and kept current by a ResizeObserver, because the
   pull-tab drawer changes the panel width when it opens. */
.cs2__site {
  --bracket-color: var(--phosphor-dim);
  flex: 0 0 auto;
  border: var(--border-hair);
  background: #000;
}

.cs2__site-chrome {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: var(--gap-sm);
  padding: var(--gap-xs) var(--gap-sm);
  border-bottom: var(--border-hair);
  color: var(--phosphor-mut);
  font-family: var(--font-mono);
  font-size: 0.714rem;
  letter-spacing: var(--tracking-label);
  text-transform: uppercase;
}

.cs2__site-label { color: var(--phosphor); }

.cs2__site-frame {
  position: relative;
  overflow: hidden;
  background: #000;
}

/* transform-origin top-left keeps the scaled frame pinned to the
   wrapper's corner; without it the scale pulls away from centre and
   leaves a gap on two sides. */
.cs2__site-frame > iframe {
  display: block;
  border: 0;
  transform-origin: top left;
}

.cs2__site-loading {
  position: absolute;
  inset: 0;
  display: flex;
  flex-direction: column;
  align-items: center;
  /* Anchored near the top, NOT centred. The frame wrapper is as tall as
     the scaled desktop viewport (~900px), which is usually taller than
     the visible panel — centring in it pushed the disclaimer below the
     fold, where the one message that has to be read before the work is
     judged would have needed scrolling to find. */
  justify-content: flex-start;
  padding-top: clamp(56px, 18%, 150px);
  gap: var(--gap-sm);
  background: var(--bg);
  color: var(--phosphor-mut);
  font-family: var(--font-mono);
  font-size: 0.786rem;
  letter-spacing: var(--tracking-label);
  /* Never swallows clicks — a visitor must be able to keep using the
     rest of the page (and navigate away) while this is still filling. */
  pointer-events: none;
  transition: opacity 0.35s ease;
}

.cs2__site-loading.is-done {
  opacity: 0;
  visibility: hidden;
}

.cs2__site-bar {
  display: block;
  width: min(260px, 60%);
  height: 3px;
  background: var(--phosphor-dim);
}

.cs2__site-bar i {
  display: block;
  height: 100%;
  background: var(--phosphor);
  transition: width 0.25s linear;
}

/* Sets expectations before the viewer forms an opinion about the work:
   what they are about to see is a compressed reconstruction, not the
   delivered site. Sentence case and normal tracking on purpose — this is
   the one place on the page that has to read as prose rather than as
   HUD chrome, or nobody will actually read it. */
.cs2__site-note {
  max-width: 46ch;
  margin: 0;
  padding: 0 var(--gap-md);
  color: var(--phosphor-mut);
  font-size: 0.714rem;
  line-height: 1.65;
  letter-spacing: 0;
  text-transform: none;
  text-align: center;
}

.cs2__gallery .case-study__media {
  columns: 220px 4;
}

.cs2__gallery .gallery-grid {
  columns: 220px 4;
}

@media (max-width: 600px) {
  .case-study__media,
  .cs2__gallery .case-study__media,
  .gallery-grid,
  .cs2__gallery .gallery-grid,
  .pres-case-study__stage .gallery-grid {
    columns: 2;
  }

  /* One per row on a phone. A 16:9 motion piece at half a phone's width is
     ~180px — below the point where anything in it can be read.
     The .is-gallery qualifier is load-bearing: a media query adds no
     specificity, and the wide m3d track rule sits LATER in this file at the
     same specificity, so without the extra class it wins here on source
     order and this rule never applies. */
  .m3d-case-study__stage.is-gallery .gallery-grid {
    columns: 1;
  }
}

/* ---- booklet viewer (PDF.js) --------------------------------------------
   Renders a project's booklet.pdf(s) client-side onto <canvas> (see
   js/booklet.js) rather than exported page images, so this styling never
   has to match whatever a specific PDF export happened to look like — it's
   entirely our own chrome around someone else's page.
   .gd-booklet__page is width-driven (fits the stage's width, height follows
   from --booklet-aspect) rather than height-driven like .pres-deck — unlike
   Presentations' single full-height stage, .cs2__gallery is a scrolling
   flex column that can hold a project's image grid AND one or more
   booklets stacked with no definite height to inherit, the same reason
   .video-grid below is width-driven instead of copying
   .case-study__video--embed's height trick. --booklet-aspect is read from
   the real PDF's first page (already rotation-corrected) once it loads. */
.gd-booklet {
  --bracket-color: var(--phosphor-dim);
  display: flex;
  flex-direction: column;
  /* .cs2__gallery is itself a flex column with overflow-y: auto — the
     scroll IS the intended fit mechanism, so a booklet must keep its
     natural height rather than being shrunk to fit. flex-shrink: 0 does
     that; min-height: 0 (a flex child's default automatic-minimum
     override, used elsewhere on this site for children that SHOULD
     collapse) is deliberately absent here — with it, shrinking one or
     more full-height booklets stacked next to a project's image grid
     crushed each one down to ~4px instead of scrolling. */
  flex-shrink: 0;
  border: var(--border-hair);
  background: #000;
}

.gd-booklet__chrome {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: var(--gap-sm);
  padding: var(--gap-xs) var(--gap-sm);
  border-bottom: var(--border-hair);
  color: var(--phosphor-mut);
  font-family: var(--font-mono);
  font-size: 0.714rem;
  letter-spacing: var(--tracking-label);
  text-transform: uppercase;
}

.gd-booklet__label {
  color: var(--phosphor);
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

.gd-booklet__download {
  flex: 0 0 auto;
  color: var(--phosphor-mut);
  text-decoration: none;
}
.gd-booklet__download:hover,
.gd-booklet__download:focus-visible {
  color: var(--phosphor-hi);
}

.gd-booklet__stage {
  display: flex;
  justify-content: center;
  padding: var(--gap-sm);
}

/* The two-page spread — book-style, side by side, rather than one page at
   a time. justify-content: center matters more than it looks like it
   should: with only one page visible (the cover, or a final odd page),
   there's no second flex item to balance the row, so without it that lone
   page would hug the left edge instead of sitting centered like a real
   book. */
.gd-booklet__spread {
  display: flex;
  justify-content: center;
  gap: 2px; /* a hairline gutter, like a real book's spine crease */
  width: 100%;
  max-width: 900px;
}

/* Both max-width and max-height are load-bearing, and both are sized
   relative to the SPREAD, not to how many pages happen to be showing:
   - max-width: min(450px, 50% - half the gutter) is HALF a spread's width
     whether one page is visible or two — the earlier version used
     `flex: 1 1 0` (stretch to fill the row), which meant a lone cover with
     no sibling to share the row with stretched to the FULL spread width,
     and, since height only ever came from width via aspect-ratio, that
     made the cover roughly twice as tall as a normal two-up spread. Fixing
     the width fixes the height for free, since both derive from the same
     aspect-ratio.
   - max-height: min(380px, 45vh) is the belt-and-suspenders version of
     the same idea, and the part that actually answers "give it a max
     height so they're consistent" — it caps every page (cover or paired)
     at the same height regardless of the document's own aspect ratio, and
     the vh term keeps that height honest on a short viewport (a landscape
     phone) without a dedicated breakpoint: 45vh of a ~360-400px-tall
     landscape phone screen is naturally smaller than 380px, so it shrinks
     there automatically, while a normal desktop window is tall enough that
     45vh exceeds 380px and the fixed cap wins instead.
   width/height: auto (not the 100% used everywhere else on this site) is
   what lets aspect-ratio, rather than the flex row, decide the box's
   actual size within those two caps — flex: 0 0 auto opts out of the
   stretch/shrink behavior that made the old width-driven approach fold
   in the first bug. */
.gd-booklet__page {
  --booklet-aspect: 1.294; /* fallback until js/booklet.js reads the real page */
  position: relative;
  flex: 0 0 auto;
  width: auto;
  height: auto;
  max-width: min(450px, calc(50% - 1px));
  max-height: min(380px, 45vh);
  aspect-ratio: var(--booklet-aspect);
  background: var(--bg-panel);
}

.gd-booklet__canvas {
  display: block;
  width: 100%;
  height: 100%;
}

/* Navigation lives in its own row under the spread (Mike's call) rather
   than flanking the pages, so it reads as a single control strip instead
   of two floating side buttons. */
.gd-booklet__pagenav {
  display: flex;
  align-items: center;
  justify-content: center;
  gap: var(--gap-md);
  padding: 0 var(--gap-sm) var(--gap-sm);
}

.gd-booklet__counter {
  flex: 0 0 auto;
  color: var(--phosphor-mut);
  font-family: var(--font-mono);
  font-size: 0.786rem;
  letter-spacing: var(--tracking-label);
  text-transform: uppercase;
  font-variant-numeric: tabular-nums;
}

.gd-booklet__nav {
  flex: 0 0 auto;
  background: none;
  border: var(--border-hair);
  color: var(--phosphor-mut);
  font-family: var(--font-mono);
  font-size: 0.786rem;
  letter-spacing: var(--tracking-label);
  text-transform: uppercase;
  padding: var(--gap-xs) var(--gap-sm);
  min-width: 84px;
  cursor: pointer;
  transition: color 0.15s ease, border-color 0.15s ease;
}
.gd-booklet__nav:hover,
.gd-booklet__nav:focus-visible {
  color: var(--phosphor-hi);
  border-color: var(--phosphor-mut);
}
.gd-booklet__nav:disabled {
  opacity: 0.3;
  cursor: default;
}

.gd-booklet__filmstrip {
  display: flex;
  gap: var(--gap-xs);
  overflow-x: auto;
  padding: var(--gap-xs) var(--gap-sm);
  border-top: var(--border-hair);
}

.gd-booklet__thumb {
  flex: 0 0 auto;
  width: 64px;
  padding: 0;
  border: var(--border-hair);
  background: #000;
  cursor: pointer;
  position: relative;
  line-height: 0;
}
.gd-booklet__thumb canvas {
  display: block;
  width: 100%;
  height: auto;
}
.gd-booklet__thumb.is-active {
  border-color: var(--phosphor);
  box-shadow: var(--glow-panel);
}
.gd-booklet__thumb-num {
  position: absolute;
  bottom: 2px;
  right: 2px;
  font-family: var(--font-mono);
  font-size: 0.6rem;
  color: var(--phosphor-hi);
  background: rgba(0, 0, 0, 0.6);
  padding: 0 2px;
}

@media (max-width: 600px), (pointer: coarse) {
  .gd-booklet__nav {
    min-width: 44px;
    min-height: 44px;
  }
}

/* Portrait only, deliberately — not the (pointer: coarse) catch-all every
   other mobile override on this site uses. A portrait phone is narrow: two
   pages side by side at half that width would each render at ~180px,
   below the point where anything on the page is readable (the same floor
   the Motion & 3D multi-video grid comment uses), so they stack instead,
   each getting the full width. A LANDSCAPE phone has the opposite problem
   — plenty of width, very little height — so stacking there would make
   things worse, not better: two already-landscape-shaped pages stacked
   would burn twice the scarce vertical space for no readability gain.
   Side by side + .gd-booklet__page's own `max-height: min(380px, 45vh)`
   already shrinks correctly for a short landscape viewport with no help
   needed here. */
@media (max-width: 600px) and (orientation: portrait) {
  .gd-booklet__spread {
    flex-direction: column;
  }

  .gd-booklet__page {
    max-width: 100%;
  }
}

/* The whole drawer — tab + panel — slides as one unit. Closed, it sits
   shifted right by its own width minus the tab, so only the tab peeks out
   past the gallery's right edge; open, it slides fully into view. */
.cs2__drawer {
  position: absolute;
  top: 0;
  right: 0;
  bottom: 0;
  width: min(460px, 90%);
  display: flex;
  transform: translateX(calc(100% - var(--cs2-tab-w)));
  transition: transform 0.35s ease;
  z-index: 5;
}

.cs2__drawer.is-open {
  transform: translateX(0);
}

.cs2__tab {
  flex: 0 0 var(--cs2-tab-w);
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: var(--gap-xs);
  padding: var(--gap-sm) 0;
  background: var(--bg-panel);
  /* Only a left divider — top/bottom/right rely on .cs2's own outer border
     so the drawer reads as flush with the rest of the grid instead of
     stacking a second border directly against the panel's edge. */
  border: none;
  border-left: var(--border-hair);
  color: var(--phosphor-mut);
  cursor: pointer;
  transition: color 0.2s ease, text-shadow 0.2s ease;
}

.cs2__tab:hover,
.cs2__tab:focus-visible {
  color: var(--phosphor-hi);
}

.cs2__tab:hover .cs2__tab-label,
.cs2__tab:focus-visible .cs2__tab-label,
.cs2__tab:hover .cs2__tab-icon,
.cs2__tab:focus-visible .cs2__tab-icon {
  text-shadow: var(--glow-text);
}

.cs2__tab-label {
  writing-mode: vertical-rl;
  letter-spacing: var(--tracking-label);
  font-size: 0.786rem;
  text-transform: uppercase;
}

/* Glyph is "›" throughout — rotated 180° by default so it reads as "‹"
   (collapsed = pull this way), then un-rotated to "›" once open
   (expanded = push this way to close). */
.cs2__tab-icon {
  display: inline-block;
  font-size: 0.9rem;
  transform: rotate(180deg);
  transition: transform 0.35s ease;
}

.cs2__drawer.is-open .cs2__tab-icon {
  transform: rotate(0deg);
}

/* One-time onboarding nudge — see runPullTabIntro() in main.js. Color and
   glow both inherit down to the label/icon since they don't set their own. */
@keyframes cs2-tab-blink {
  0%,
  100% {
    color: var(--phosphor-mut);
    text-shadow: none;
  }
  50% {
    color: var(--phosphor-hi);
    text-shadow: var(--glow-text);
  }
}

.cs2__tab--blink {
  animation: cs2-tab-blink 0.2s ease-in-out 4;
}

.cs2__drawer-panel {
  flex: 1 1 auto;
  min-width: 0;
  overflow-y: auto;
  background: var(--bg-panel);
  border: none;
  padding: var(--gap-md);
  box-shadow: -12px 0 28px -10px rgba(0, 0, 0, 0.65);
}

.cs2__drawer-panel .case-study__meta {
  margin-bottom: var(--gap-sm);
}

/* ---- generative ai: locked category tile ------------------------------ */

.gd-category.is-locked {
  cursor: not-allowed;
  opacity: 0.5;
}

.gd-category.is-locked:hover,
.gd-category.is-locked:focus-visible {
  animation: none;
  color: var(--phosphor);
  border-color: var(--phosphor-dim);
}

.gd-category__lock {
  font-size: 0.714rem;
  letter-spacing: var(--tracking-label);
  color: var(--alert);
}

.gd-categories.is-nested .gd-category__lock {
  font-size: 0.643rem;
}

/* ---- landing quadrant: protected-section badge ------------------------ */

.quadrant__lock {
  position: absolute;
  left: var(--gap-md);
  top: var(--gap-md);
  font-size: 0.714rem;
  letter-spacing: var(--tracking-label);
  color: var(--phosphor-mut);
}

/* ---- presentations: thumbnail flexbox + expand-to-fill viewer -------- */

/* Same shape as .m3d-body: list rail beside the detail pane, and it never
   scrolls itself — that's what gives the case study a definite height to
   divide between deck and copy. If this scrolled, the deck frame would grow
   to whatever height it liked and the stage could never be height-restricted.
   Children own their own scrolling (the gate centres, the copy scrolls). */
.pres-body {
  flex: 1 1 auto;
  min-height: 0;
  display: flex;
  gap: var(--gap-sm);
  padding: var(--gap-sm);
  overflow: hidden;
}

/* Bisected the same way as .m3d-case-study — deck stage on top, peeked
   case-study copy below — so the deck leads visually and the writing is
   there for anyone who wants it. */
.pres-case-study {
  flex: 1 1 auto;
  min-height: 0;
  display: flex;
  flex-direction: column;
  gap: var(--gap-sm);
  padding: var(--gap-md);
  background: var(--bg-panel);
  border: var(--border-hair);
}

/* ---- presentations: live deck (OneDrive embed) -------------------------

   Every deck is authored 16:9 (verified against <p:sldSz> in each .pptx),
   but Microsoft's viewer draws its own slide-nav bar *inside* the frame and
   fits the slide into whatever height is left. Sizing the box to a flat
   16/9 therefore letterboxes the slide by exactly the height of that bar.
   box-sizing: content-box makes aspect-ratio govern the content box alone,
   so --deck-chrome adds the bar's height on top of a true 16:9 slide
   instead of stealing it from the slide. */
/* Height-driven, not width-driven — the same trick .case-study__video--embed
   uses in the Motion & 3D stage. Sizing from height is what actually
   restricts the frame: the deck takes the height the stage gives it and
   derives its own width from that, so it can never push past the stage and
   over the copy panel below, at any viewport size.

   --deck-chrome is subtracted from the height and added back as padding, so
   the 16:9 applies to the slide itself and the nav bar sits underneath it
   rather than eating into it. Total height stays exactly 100% of the stage. */
.pres-deck {
  --deck-chrome: 28px;
  box-sizing: content-box;
  position: relative;
  height: calc(100% - var(--deck-chrome));
  width: auto;
  max-width: 100%;
  aspect-ratio: 16 / 9;
  padding-bottom: var(--deck-chrome);
  background: var(--bg-panel);
}

/* Facade and iframe both fill the padding box, so the framing doesn't shift
   when the click swaps one for the other. */
.pres-deck > iframe {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
}

.pres-deck > iframe {
  display: block;
  border: var(--border-hair);
  background: #000;
}

/* ---- presentations: password gate -------------------------------------- */

.pres-gate {
  --bracket-color: var(--phosphor-dim);
  max-width: 56ch;
  margin: auto;
  display: flex;
  flex-direction: column;
  gap: var(--gap-md);
  padding: var(--gap-md);
  background: var(--bg-panel);
  border: var(--border-hair);
}

.pres-gate__title {
  margin: 0;
  font-size: 1.143rem;
  font-style: italic;
  color: var(--phosphor-hi);
  text-shadow: var(--glow-text);
}

.pres-gate__body {
  margin: 0;
  color: var(--phosphor-mut);
  font-size: 0.929rem;
  line-height: 1.6;
}

/* Secondary to the ask above it — sits below the form as a caveat about the
   embedded viewer's fidelity, dimmed and set off by a rule so it reads as a
   footnote rather than competing with the password prompt. */
.pres-gate__note {
  margin: 0;
  padding-top: var(--gap-sm);
  border-top: var(--border-hair);
  color: var(--phosphor-mut);
  font-size: 0.857rem;
  line-height: 1.55;
}

.pres-gate__body a {
  color: var(--phosphor);
}

.pres-gate__body a:hover,
.pres-gate__body a:focus-visible {
  color: var(--phosphor-hi);
}

.pres-gate__form {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: var(--gap-sm);
}

.pres-gate__label {
  flex: 0 0 auto;
  font-size: 0.857rem;
  letter-spacing: var(--tracking-label);
  color: var(--phosphor-mut);
}

.pres-gate__input {
  flex: 1 1 160px;
  background: var(--bg);
  border: var(--border-hair);
  color: var(--phosphor);
  font-family: var(--font-mono);
  font-size: 0.929rem;
  padding: var(--gap-xs) var(--gap-sm);
}

.pres-gate__input:focus-visible {
  outline: 2px solid var(--phosphor-mut);
  outline-offset: 1px;
}

.pres-gate__submit {
  flex: 0 0 auto;
  background: none;
  border: var(--border-hair);
  color: var(--phosphor);
  font-family: var(--font-mono);
  font-size: 0.857rem;
  letter-spacing: var(--tracking-label);
  padding: var(--gap-xs) var(--gap-md);
  cursor: pointer;
}

.pres-gate__submit:hover,
.pres-gate__submit:focus-visible {
  color: var(--phosphor-hi);
  border-color: var(--phosphor-mut);
}

/* Wrong password: the button kicks back and flares for 250ms. Everything
   returns to its base value at 100% and fill-mode stays default, so the
   button is back to normal the moment it ends — the class can sit on the
   element until the next attempt restarts it (see renderGate in main.js). */
@keyframes pres-gate-reject {
  0% {
    transform: translateX(0);
    color: var(--phosphor);
    border-color: var(--phosphor-dim);
    text-shadow: none;
  }
  25% {
    transform: translateX(-4px);
    color: var(--phosphor-hi);
    border-color: var(--alert);
    text-shadow: var(--glow-text);
  }
  55% {
    transform: translateX(4px);
    color: var(--phosphor-hi);
    border-color: var(--alert);
    text-shadow: var(--glow-text);
  }
  80% {
    transform: translateX(-2px);
    color: var(--phosphor-hi);
    border-color: var(--alert);
    text-shadow: var(--glow-text);
  }
  100% {
    transform: translateX(0);
    color: var(--phosphor);
    border-color: var(--phosphor-dim);
    text-shadow: none;
  }
}

/* Reduced motion keeps the flare, drops the travel. */
@keyframes pres-gate-reject-flash {
  0%,
  100% {
    color: var(--phosphor);
    border-color: var(--phosphor-dim);
    text-shadow: none;
  }
  50% {
    color: var(--phosphor-hi);
    border-color: var(--alert);
    text-shadow: var(--glow-text);
  }
}

.pres-gate__submit.is-rejected {
  animation: pres-gate-reject 0.25s ease-in-out;
}

@media (prefers-reduced-motion: reduce) {
  .pres-gate__submit.is-rejected {
    animation-name: pres-gate-reject-flash;
  }
}

.pres-gate__error {
  margin: 0;
  color: var(--alert);
  font-size: 0.857rem;
}

.pres-gate__error[hidden] {
  display: none;
}

/* ---- motion & 3d: flat list column + bisected video/copy master ------ */

.m3d-body {
  flex: 1 1 auto;
  display: flex;
  gap: var(--gap-sm);
  padding: var(--gap-sm);
  min-height: 0;
}

/* Same shell/list split as .gd-projects, for the same mobile-drawer reason. */
.m3d-list,
.pres-list {
  flex: 0 0 300px;
  display: flex;
  flex-direction: column;
  min-height: 0;
}

.pres-list[hidden] {
  display: none;
}

.m3d-list__list,
.pres-list__list {
  flex: 1 1 auto;
  min-height: 0;
  display: flex;
  flex-direction: column;
  gap: var(--gap-xs);
  overflow-y: auto;
}

.m3d-detail,
.pres-detail {
  flex: 1 1 auto;
  min-width: 0;
  min-height: 0;
  display: flex;
  flex-direction: column;
}

/* master panel: bisected — video stage on top, scrollable copy below, so
   a video can keep playing while the case-study text scrolls under it */
.m3d-case-study {
  flex: 1 1 auto;
  min-height: 0;
  display: flex;
  flex-direction: column;
  gap: var(--gap-sm);
  padding: var(--gap-md);
  background: var(--bg-panel);
  border: var(--border-hair);
}

.m3d-case-study__header {
  flex: 0 0 auto;
  display: flex;
  flex-wrap: wrap;
  align-items: baseline;
  gap: var(--gap-sm);
}

.m3d-case-study__title {
  flex: 1 1 auto;
  margin: 0;
  font-size: 0.8rem;
  font-weight: 400;
  letter-spacing: var(--tracking-label);
  text-transform: uppercase;
  text-shadow: var(--glow-text);
}

/* Never carried a font-size of its own — it rode the browser's default h2
   sizing, which in the display face lands far bigger than the sibling
   case-study titles. Pinned to match .cs2__title. */
.pres-case-study__title {
  margin: 0;
  font-size: 0.9rem;
  font-weight: 400;
  text-transform: uppercase;
  text-shadow: var(--glow-text);
}

/* Stage has no size of its own — it just claims whatever the copy panel
   isn't currently using, so it grows automatically as copy nests/expands.
   overflow:hidden clips the video to this box — without it, an embed held
   at its min-height (see .case-study__video--embed) would render past the
   stage's edge and on top of the copy panel below. */
.m3d-case-study__stage,
.pres-case-study__stage {
  flex: 1 1 auto;
  min-height: 0;
  display: flex;
  align-items: center;
  justify-content: center;
  overflow: hidden;
  border: var(--border-hair);
  background: var(--bg);
  color: var(--phosphor-mut);
  font-size: 0.929rem;
  text-align: center;
  padding: var(--gap-md);
}

/* A gallery is a scrolling grid, not one centred object like a video or a
   deck, so it starts at the top and scrolls inside the stage rather than
   being centred and clipped by it. The track width matches the Graphic
   Design case-study galleries (.cs2__gallery) so every bento gallery on the
   site lays out identically. */
.m3d-case-study__stage.is-gallery,
.pres-case-study__stage.is-gallery {
  align-items: flex-start;
  overflow-y: auto;
}

/* Presentations keeps the site-wide photo-wall track. Motion & 3D does not:
   its galleries hold 16:9 motion pieces and infographics with real body copy
   in them, and at a 220px track the Data Medallion infographic rendered
   345px wide — small enough that its own labels were unreadable. A wide
   track is the point here, not a deviation for its own sake. Clicking any
   tile still opens it full size in the lightbox. */
.pres-case-study__stage .gallery-grid {
  columns: 220px 4;
}

.m3d-case-study__stage .gallery-grid {
  columns: 460px 2;
}

/* Nested by default (room for the video); hovering the copy itself expands
   it for reading. Entering has a short delay so passing near the edge on
   your way elsewhere doesn't trigger it — leaving reacts immediately so
   the video reclaims space right away. */
.m3d-case-study__copy,
.pres-case-study__copy {
  position: relative;
  flex: 0 0 76px;
  min-height: 0;
  overflow-y: auto;
  border: var(--border-hair);
  padding: var(--gap-sm) var(--gap-md);
  color: var(--phosphor-mut);
  font-size: 0.929rem;
  line-height: 1.6;
  transition: flex-basis 0.45s ease;
}

@media (hover: hover) {
  .m3d-case-study__copy:hover,
  .pres-case-study__copy:hover {
    flex-basis: 50%;
    transition-delay: 0.2s;
  }
}

.m3d-case-study__copy p,
.pres-case-study__copy p {
  margin: 0 0 var(--gap-sm);
}

.m3d-case-study__copy p:last-child,
.pres-case-study__copy p:last-child {
  margin-bottom: 0;
}

/* Fades the peeked text to a soft edge instead of a hard cut, and hints
   there's more to read — fades out once expanded. */
.m3d-case-study__copy::after,
.pres-case-study__copy::after {
  content: "";
  position: absolute;
  inset: auto 0 0 0;
  height: 28px;
  background: linear-gradient(to bottom, transparent, var(--bg-panel));
  opacity: 1;
  transition: opacity 0.3s ease;
  pointer-events: none;
}

@media (hover: hover) {
  .m3d-case-study__copy:hover::after,
  .pres-case-study__copy:hover::after {
    opacity: 0;
    transition-delay: 0.2s;
  }
}

/* ---- about: dossier-style bio + contact ------------------------------ */

/* Three matched-height columns — grid's default item stretch means photo,
   dossier, and contact all take the height of the tallest one for free.
   Contact sits leftmost (that's the habitual click target), bio in the
   middle at roughly double width, photo rightmost. */
.about-body {
  flex: 1 1 auto;
  display: grid;
  grid-template-columns: 1fr 3fr 2fr;
  gap: var(--gap-sm);
  padding: var(--gap-sm);
  min-height: 0;
  /* The panel itself does NOT scroll — the bio column does (see
     .about-dossier). While the whole body scrolled, the grid row was as tall
     as the bio and the photo column ran off the bottom of the screen with
     it, so the bento could never fit the visible space. Non-scrolling here
     is also how .m3d-body and .pres-body already work. */
  overflow: hidden;
}

.about-photo {
  position: relative;
  display: flex;
  align-items: center;
  justify-content: center;
  background: var(--bg-panel);
  border: var(--border-hair);
  color: var(--phosphor-mut);
  font-size: 0.786rem;
  letter-spacing: var(--tracking-label);
  text-transform: uppercase;
  overflow: hidden;
}

/* The photo slot is a bento wall, deliberately WITHOUT the duotone/halftone
   bitmap treatment the landing quadrants use — these read as photographs,
   not as terminal output. Absolutely positioned so .about-photo's flex
   centring (which exists for the "// NO PHOTO_" empty state) can't stretch
   or centre the grid instead of letting it fill the box.

   Track counts come from renderAboutBento() as inline styles, because they
   depend on how many files are in the folder. Both axes are 1fr, so the
   grid divides the height the column inherits from the bio exactly — it
   fits whatever space it's given rather than scrolling. */
.about-bento {
  position: absolute;
  inset: 0;
  display: grid;
  gap: 1px;
  background: var(--phosphor-dim);
}

/* Each tile is a button, not a bare image — clicking one opens it in the
   gallery lightbox uncropped. min-height/min-width: 0 keeps a grid item from
   refusing to shrink below its image's intrinsic size, which is what would
   otherwise push the grid past the box and reintroduce a scrollbar. */
.about-bento__tile {
  display: block;
  width: 100%;
  height: 100%;
  min-width: 0;
  min-height: 0;
  padding: 0;
  border: 0;
  background: var(--bg-panel);
  cursor: pointer;
  overflow: hidden;
}

.about-bento__tile img {
  display: block;
  width: 100%;
  height: 100%;
  object-fit: cover;
  transition: opacity 0.2s ease;
}

/* An outline rather than a border, so the hover state can't shift the tile's
   own box and re-flow the grid under the cursor. Inset so it reads as an
   edge on the tile instead of bleeding into the hairline gap. */
.about-bento__tile:hover,
.about-bento__tile:focus-visible {
  outline: var(--border-hair);
  outline-offset: -2px;
}

.about-bento__tile:hover img,
.about-bento__tile:focus-visible img {
  opacity: 0.82;
}

.about-dossier {
  min-width: 0;
  /* min-height: 0 is what actually lets this scroll: without it a grid item
     floors at its content's height and overflows the row instead. */
  min-height: 0;
  display: flex;
  flex-direction: column;
  background: var(--bg-panel);
  border: var(--border-hair);
  padding: var(--gap-md);
  overflow-y: auto;
}

/* name/role/bio as one block, centered in the wider bio column */
.about-dossier__body {
  align-self: center;
  max-width: 55ch;
}

.about-dossier__name {
  margin: 0;
  font-size: 1.1rem;
  font-weight: 400;
  letter-spacing: var(--tracking-label);
  text-transform: uppercase;
  text-shadow: var(--glow-text);
}

.about-dossier__role {
  display: block;
  margin-top: 2px;
  margin-bottom: var(--gap-sm);
  font-size: 0.929rem;
  letter-spacing: var(--tracking-label);
  color: var(--phosphor-mut);
  text-transform: uppercase;
}

.about-dossier__bio {
  margin: 0 0 var(--gap-md);
  color: var(--phosphor-mut);
  font-size: 0.929rem;
  line-height: 1.7;
}

.about-dossier__bio:last-child {
  margin-bottom: 0;
}

.about-contact {
  min-width: 0;
  display: flex;
  flex-direction: column;
  gap: var(--gap-sm);
}

.about-contact__row {
  display: flex;
  flex-direction: column;
  align-items: flex-start;
  gap: 2px;
  padding: var(--gap-sm) var(--gap-md);
  background: var(--bg-panel);
  border: var(--border-hair);
  color: var(--phosphor);
  text-decoration: none;
  transition: border-color 0.15s ease, color 0.15s ease;
}

.about-contact__key {
  font-size: 0.857rem;
  letter-spacing: var(--tracking-label);
  color: var(--phosphor-mut);
  text-transform: uppercase;
}

.about-contact__value {
  min-width: 0;
  overflow-wrap: anywhere;
  text-align: left;
  font-size: 0.929rem;
  letter-spacing: var(--tracking-label);
  transition: color 0.15s ease;
}

.about-contact__row:hover,
.about-contact__row:focus-visible {
  --bracket-color: var(--phosphor-hi);
  color: var(--phosphor-hi);
  border-color: var(--phosphor-mut);
}

.about-contact__row:hover .about-contact__value,
.about-contact__row:focus-visible .about-contact__value {
  text-shadow: var(--glow-text);
}

/* ==========================================================================
   Narrow (≤900px) — twin drawers
   --------------------------------------------------------------------------
   Below 900px there isn't room for two permanent nav rails around the
   content (this used to switch to a stacked column at 900px and only
   became drawers at 600px — that left tablet widths with the project list
   sitting in a plain top-to-bottom list eating half the screen). Now both
   the phone and tablet ranges get the same treatment: the two things that
   were rails become sliding drawers on opposite edges, the project list
   pulls DOWN from the top, the case-study copy pulls UP from the bottom,
   and the visual content keeps everything between them. Both use the
   tab-and-panel language the desktop case-study drawer already speaks,
   rotated 90°, and both move on transform only.

   Desktop is untouched: .mdrawer-tab is display:none and .m3d-drawer is
   display:contents, so those panels stay in normal flow above 900px.
   ========================================================================== */

.mdrawer-tab {
  display: none;
}

/* Wrapper exists only so the copy can become a drawer on narrow screens —
   above 900px it dissolves and .m3d-case-study__copy stays a direct flex
   child. */
.m3d-drawer,
.pres-drawer {
  display: contents;
}

@media (max-width: 900px) {
  /* ---- shared tab ---------------------------------------------------- */

  .mdrawer-tab {
    flex: 0 0 var(--mdrawer-tab-h);
    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: var(--gap-sm);
    padding: 0 var(--gap-md);
    background: var(--bg-panel);
    border: none;
    color: var(--phosphor-mut);
    font-family: var(--font-mono);
    cursor: pointer;
    transition: color 0.2s ease;
  }

  .mdrawer-tab:hover,
  .mdrawer-tab:focus-visible,
  .is-open > .mdrawer-tab {
    color: var(--phosphor-hi);
  }

  .mdrawer-tab:hover .mdrawer-tab__label,
  .mdrawer-tab:focus-visible .mdrawer-tab__label,
  .is-open > .mdrawer-tab .mdrawer-tab__label {
    text-shadow: var(--glow-text);
  }

  .mdrawer-tab__label {
    font-size: 0.786rem;
    letter-spacing: var(--tracking-label);
    text-transform: uppercase;
  }

  /* Same "›" glyph as the desktop drawer, pointed at the direction of
     travel: down to pull the top drawer open, up to pull the bottom one. */
  .mdrawer-tab__icon {
    display: inline-block;
    font-size: 0.9rem;
    transition: transform 0.35s ease;
  }

  /* ---- top drawer: project list -------------------------------------- */
  /* Shared by Graphic Design and Motion & 3D. The tab is the drawer's last
     child, so collapsing it off the top edge leaves the tab on screen. */

  /* ---- keeping the two sections' project bars identical --------------- */
  /* Both drawers anchor to their section's BODY box — the element that owns
     the section's gap-sm padding and sits directly under the header — so
     each one lands flush under the header at full header width with no
     per-section compensation. Anchoring GD's to .gd-detail instead (one
     level deeper, already pushed in by .gd-body's padding) is what used to
     make its stroke and background stop short of the section's outer
     bounds: reaching back out past that inset put the drawer outside
     .gd-detail's box, and .gd-detail's own overflow:hidden clipped away
     exactly the part that was supposed to meet the edge.

     overflow:hidden here is load-bearing for a second reason — collapsed,
     the drawer sits mostly above this box, and without clipping its rows
     would paint over the section header instead of disappearing behind the
     top edge. */
  .gd-body,
  .m3d-body,
  .pres-body {
    position: relative;
    flex-direction: column;
    overflow: hidden;
  }

  /* Reserving the closed tab's band goes on whichever box actually sits
     under the drawer, which is NOT the same box in both sections. For M3D
     that's the body itself. For GD it can't be: .gd-body also backs the
     category grid, which has no drawer over it, so reserving the band there
     leaves a phantom empty strip above the categories. .gd-detail is
     display:none while empty, so putting it there makes the space appear
     only where the drawer does. .gd-detail already sits gap-sm down from
     the body's own padding, so it reserves just the tab and the two
     sections still start their content at the same place. */
  .m3d-body,
  .pres-body {
    padding-top: calc(var(--mdrawer-tab-h) + var(--gap-sm));
  }

  /* Must stay static: if this gets positioned it becomes the drawer's
     containing block again and re-introduces the clipping above. */
  .gd-detail {
    flex-direction: column;
    padding-top: var(--mdrawer-tab-h);
  }

  .gd-projects,
  .m3d-list,
  .pres-list {
    position: absolute;
    top: 0;
    left: 0;
    right: 0;
    z-index: 6;
    flex: 0 0 auto;
    /* Hugs the list — a three-project category shouldn't open a half-empty
       panel — but caps out and scrolls internally on the long ones. */
    height: auto;
    max-height: min(62%, 420px);
    display: flex;
    flex-direction: column;
    overflow: hidden;
    background: var(--bg-panel);
    /* Horizontal strokes only. The drawer spans the full width of the
       terminal frame's inner edge, so its own left/right strokes would land
       directly alongside the frame's border rather than on top of it — two
       2px lines reading as one 4px line, but only in the drawer's band.
       The frame drives the vertical edges instead; these two run the full
       width and meet it flush at both ends. They're on the same box that
       transforms, so they ride along with the slide for free. */
    border-top: var(--border-hair);
    border-bottom: var(--border-hair);
    transform: translateY(calc(-100% + var(--mdrawer-tab-h)));
    transition: transform 0.35s ease;
    box-shadow: 0 12px 28px -10px rgba(0, 0, 0, 0.65);
  }

  .gd-projects.is-open,
  .m3d-list.is-open,
  .pres-list.is-open {
    transform: translateY(0);
  }

  /* Closed, the drawer's box still geometrically overlaps the section header
     above it — the transform moves it off-screen visually but doesn't clip
     its hit-testable area, so without this the whole drawer (list AND its
     own container box) swallows taps meant for the header's home button
     underneath. pointer-events:none cascades to children, so the tab is
     opted back in explicitly to stay clickable. */
  .gd-projects:not(.is-open),
  .m3d-list:not(.is-open),
  .pres-list:not(.is-open) {
    pointer-events: none;
  }

  .gd-projects:not(.is-open) .mdrawer-tab,
  .m3d-list:not(.is-open) .mdrawer-tab,
  .pres-list:not(.is-open) .mdrawer-tab {
    pointer-events: auto;
  }

  .gd-projects__list,
  .m3d-list__list,
  .pres-list__list {
    /* Matches the section header's own padding rhythm (gap-sm/gap-md) so
       the list reads as part of the same bar system as the header, not a
       separately-styled panel. */
    padding: var(--gap-sm) var(--gap-md);
    /* No inter-row gap here — rows collapse into one shared stroke per
       seam below, table-cell style, instead of the desktop sidebar's
       independently-floating bordered cards. */
    gap: 0;
  }

  /* Border-collapse, the CSS-table way: every row keeps its own full
     border, EXCEPT the top edge, which is dropped on every row but the
     first so it doesn't stack on top of the row above's bottom edge —
     each seam ends up drawn by exactly one 2px line instead of two
     doubling up to ~4px. The outer .gd-projects/.m3d-list border remains
     the table's own frame around the whole stack. */
  .gd-projects__list .project-row + .project-row,
  .m3d-list__list .project-row + .project-row,
  .pres-list__list .project-row + .project-row {
    border-top: none;
  }

  .gd-projects .mdrawer-tab,
  .m3d-list .mdrawer-tab,
  .pres-list .mdrawer-tab {
    border-top: var(--border-hair);
    padding: var(--gap-sm) var(--gap-md);
    /* Label + chevron read as one centered unit — this tab is a closed
       stand-in for the section header, not a label-left/icon-right control. */
    justify-content: center;
  }

  .gd-projects .mdrawer-tab__icon,
  .m3d-list .mdrawer-tab__icon,
  .pres-list .mdrawer-tab__icon {
    transform: rotate(90deg);
  }

  .gd-projects.is-open .mdrawer-tab__icon,
  .m3d-list.is-open .mdrawer-tab__icon,
  .pres-list.is-open .mdrawer-tab__icon {
    transform: rotate(-90deg);
  }

  /* The nested category strip is redundant on mobile — the home button in
     the section header is the way out, and the strip cost a whole band. */
  .gd-categories.is-nested {
    display: none;
  }

  /* ---- bottom drawer: graphic design case study ----------------------- */
  /* The desktop drawer slides in from the right; here the same element is
     re-anchored to the bottom edge and slides up over the gallery. */

  .cs2 {
    --cs2-tab-h: var(--mdrawer-tab-h);
  }

  .cs2__gallery {
    padding: var(--gap-sm) var(--gap-sm) calc(var(--gap-sm) + var(--cs2-tab-h));
  }

  .cs2__drawer {
    top: auto;
    right: 0;
    bottom: 0;
    left: 0;
    width: auto;
    height: min(70%, 460px);
    flex-direction: column;
    transform: translateY(calc(100% - var(--cs2-tab-h)));
  }

  .cs2__drawer.is-open {
    transform: translateY(0);
  }

  /* Same off-screen-but-still-hit-testable issue as the top project drawer,
     mirrored at the bottom edge — the closed panel (container included, not
     just its content) can otherwise swallow taps meant for content above it.
     pointer-events:none cascades to children, so the tab opts back in. */
  .cs2__drawer:not(.is-open) {
    pointer-events: none;
  }

  .cs2__drawer:not(.is-open) .cs2__tab {
    pointer-events: auto;
  }

  .cs2__tab {
    flex: 0 0 var(--cs2-tab-h);
    flex-direction: row;
    align-items: center;
    justify-content: space-between;
    padding: 0 var(--gap-md);
    border-left: none;
    border-top: var(--border-hair);
  }

  .cs2__tab-label {
    writing-mode: horizontal-tb;
  }

  .cs2__tab-icon {
    transform: rotate(-90deg);
  }

  .cs2__drawer.is-open .cs2__tab-icon {
    transform: rotate(90deg);
  }

  .cs2__drawer-panel {
    box-shadow: 0 -12px 28px -10px rgba(0, 0, 0, 0.65);
  }

  /* ---- bottom drawer: motion & 3d case study -------------------------- */

  .m3d-case-study,
  .pres-case-study {
    position: relative;
    padding-bottom: var(--mdrawer-tab-h);
    overflow: hidden;
  }

  .m3d-drawer,
  .pres-drawer {
    position: absolute;
    right: 0;
    bottom: 0;
    left: 0;
    z-index: 5;
    height: min(70%, 460px);
    display: flex;
    flex-direction: column;
    background: var(--bg-panel);
    border-top: var(--border-hair);
    transform: translateY(calc(100% - var(--mdrawer-tab-h)));
    transition: transform 0.35s ease;
    box-shadow: 0 -12px 28px -10px rgba(0, 0, 0, 0.65);
  }

  .m3d-drawer.is-open,
  .pres-drawer.is-open {
    transform: translateY(0);
  }

  /* Same off-screen-but-still-hit-testable issue as the top project drawer,
     mirrored at the bottom edge — the closed panel (container included, not
     just its content) can otherwise swallow taps meant for content above it.
     pointer-events:none cascades to children, so the tab opts back in. */
  .m3d-drawer:not(.is-open),
  .pres-drawer:not(.is-open) {
    pointer-events: none;
  }

  .m3d-drawer:not(.is-open) .mdrawer-tab,
  .pres-drawer:not(.is-open) .mdrawer-tab {
    pointer-events: auto;
  }

  .m3d-drawer .mdrawer-tab__icon,
  .pres-drawer .mdrawer-tab__icon {
    transform: rotate(-90deg);
  }

  .m3d-drawer.is-open .mdrawer-tab__icon,
  .pres-drawer.is-open .mdrawer-tab__icon {
    transform: rotate(90deg);
  }

  /* Inside the drawer the copy is just the scroll region — the collapsed
     peek and its fade cue belong to the desktop hover behaviour. */
  .m3d-case-study__copy,
  .pres-case-study__copy {
    flex: 1 1 auto;
    min-height: 0;
    border: none;
    transition: none;
  }

  .m3d-case-study__copy::after,
  .pres-case-study__copy::after {
    display: none;
  }

  /* Prev/next is redundant now that the full list is a tap away, and it
     was eating a band of the stage. */
  .m3d-case-study .case-study__nav,
  .pres-case-study .case-study__nav {
    display: none;
  }
}

/* ---- collapsing header on scroll (mobile/touch only) ------------------
   Scrolling down inside a project's own content folds the section header
   (HOME/PROJECTS) and the project's own title bar out of the way, so a
   short viewport gets that space back for the thing actually being looked
   at — a landscape phone especially has almost none to spare (reported
   directly from one, 2026-09-14: two booklet pages plus their filmstrip
   left almost nothing visible under the header + title). Scrolling back
   near the top brings both back. wireCollapsingHeader() in js/main.js
   toggles .is-header-collapsed on the section's own <div class="view">
   (a shared ancestor of both the header and the project title, which
   otherwise live in separate parts of the DOM tree) based on the
   scrolling gallery's own scrollTop — no distinction between scrolling up
   vs. down, just "near the top" vs. not, which is all that was asked for.
   Scoped to the same breakpoint as the rest of the drawer system, so
   desktop never sees this — the class is harmless there regardless. */
@media (max-width: 900px), (pointer: coarse) {
  .section-header,
  .cs2__header {
    max-height: 60px;
    overflow: hidden;
    transition: max-height 0.25s ease, padding-block 0.25s ease, border-bottom-width 0.25s ease;
  }

  .view.is-header-collapsed .section-header,
  .view.is-header-collapsed .cs2__header {
    max-height: 0;
    padding-block: 0;
    border-bottom-width: 0;
  }
}

/* ---- landscape phones: project list + GD copy drawer go back to desktop
   arrangement -------------------------------------------------------- */
/* The twin-drawer system above assumes narrow-but-TALL — a real landscape
   phone is narrow-but-SHORT, and it breaks under that assumption rather
   than just looking cramped: .cs2__drawer's `height: min(70%, 460px)`
   resolves against .cs2's ambient flex-computed height, which in a short
   viewport comes out taller than .cs2's own clipped box, so the tab ends
   up pushed entirely past .cs2's `overflow: hidden` edge — the CONTEXT
   pull-tab becomes genuinely unreachable, not just visually different
   (confirmed 2026-09-13, reported from a real phone).
   
   The fix is the one Mike proposed: don't invent a landscape layout, reuse
   the desktop one, which already has plenty of width to work with here.
   Scoped to the project list (pure navigation, equally tappable in its
   desktop form) and Graphic Design's own copy drawer (`.cs2__tab` is a
   real button on desktop too, not a hover trigger, so it's already fully
   tap-safe). `pointer: coarse` keeps this off a merely-narrow DESKTOP
   window — this is specifically for a touchscreen held sideways.
   
   Motion & 3D / Presentations' copy panel is deliberately NOT included:
   its desktop expansion is hover-only (see the `@media (hover: hover)`
   rule above), which a touchscreen can never trigger. Reverting it here
   would make that copy text unreachable by tap — a worse bug than the one
   this fixes. It keeps its current drawer, unchanged, in landscape too. */
@media (max-width: 900px) and (orientation: landscape) and (pointer: coarse) {
  /* Second correction, same day: the collapsible drawer from the fix below
     still left its own full-width "PROJECTS ›" strip sitting as its own
     row under the header — one more row this viewport can't spare. Folded
     into the header itself instead: a small HOME button, a large PROJECTS
     button beside it that IS the drawer's real open/close control (see
     .header-drawer-toggle above and wireDrawer()'s proxyToggle in
     js/main.js), and the breadcrumb path — useful context, not something
     worth a whole row here — visually hidden but still a real, focusable
     heading (see focusViewHeading() in js/main.js; the clip-rect recipe
     below matches .sr-only exactly, just inlined since that class isn't in
     this markup). The drawer's own tab is now redundant, so it's hidden
     outright rather than shrunk to a 0-height ghost still sitting in the
     tab order. */
  /* .section-header's base rule aligns children on their text BASELINE —
     fine when both were plain text, wrong now that one child is a 44px
     touch-target button and the other a 24px text link: baseline alignment
     matched their text, not their boxes, so HOME sat near the row's top
     with empty space below it while PROJECTS' box ran the full height —
     asymmetric padding that was really a mismatched-height problem. Both
     get the same height and the row centers them as boxes instead. */
  .section-header {
    align-items: center;
  }

  .home-link {
    min-height: 44px;
  }

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

  .header-drawer-toggle {
    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: var(--gap-sm);
    flex: 1 1 auto;
    min-width: 0;
    min-height: 44px;
    padding: 0 var(--gap-md);
    background: var(--bg-panel);
    border: var(--border-hair);
    color: var(--phosphor-mut);
    font-family: var(--font-mono);
    font-size: 0.786rem;
    letter-spacing: var(--tracking-label);
    text-transform: uppercase;
    cursor: pointer;
  }

  .header-drawer-toggle:hover,
  .header-drawer-toggle:focus-visible {
    color: var(--phosphor-hi);
  }

  .header-drawer-toggle__icon {
    display: inline-block;
    transform: rotate(90deg);
    transition: transform 0.35s ease;
  }

  .header-drawer-toggle[aria-expanded="true"] .header-drawer-toggle__icon {
    transform: rotate(-90deg);
  }

  .gd-projects .mdrawer-tab,
  .m3d-list .mdrawer-tab,
  .pres-list .mdrawer-tab {
    display: none;
  }

  /* Reserving room for the collapsed tab's own band (padding-top on
     .gd-detail/.m3d-body/.pres-body, all read from this same token —
     see the plain max-width:900px block above) no longer applies now
     that the tab itself is hidden — zero it out at the source instead of
     overriding every place that reads it. */
  .gd-body,
  .m3d-body,
  .pres-body {
    --mdrawer-tab-h: 0px;
  }

  /* First pass at this (2026-09-13) made the project list a permanent
     240px sidebar, matching desktop. Reported back from a real phone:
     between the section header, the category strip, and that sidebar,
     combined with the browser's own address bar (which this media query
     has no way to measure or claim back — `100dvh` above already reports
     the true, already-shrunk visible height), there was no room left for
     the gallery at all. A persistent sidebar costs WIDTH regardless of
     whether it's open, which is the wrong trade when height is the scarce
     resource. So: keep the list a collapsible drawer exactly like portrait
     (nothing to override here — it inherits that from the plain
     `max-width: 900px` block above), and spend the fix on compacting what
     stays on screen by default instead. */

  .section-header {
    padding: var(--gap-xs) var(--gap-sm);
  }

  .breadcrumb {
    font-size: 0.786rem;
  }

  /* Graphic Design's category strip (BRANDING/WEB/PRINT/...) is real
     navigation, not chrome — hiding it with no way back would be worse
     than the space it costs. Instead it travels WITH the project list
     drawer: collapsed when the drawer is, revealed when it opens, so
     picking a category and picking a project happen in the same pulldown
     instead of two separate persistent bars. `:has()` reads the drawer's
     own existing `.is-open` class (already toggled by wireDrawer() — no
     JS change needed) from a shared ancestor, since the two aren't nested
     inside each other.
     
     One custom property, set on that same shared ancestor, drives both
     halves of this so they can't drift apart: how tall the strip gets to
     grow AND how far down the drawer (position: absolute, top: 0 by
     default — see the plain max-width:900px block above) has to start
     so it doesn't just paint over the now-visible strip. z-index alone
     can't fix that — the drawer's opaque panel would still occlude
     whatever's underneath it regardless of stacking order intent, since
     both would otherwise claim the same vertical space. Matches
     .gd-category's single-row nested-mode height, with a couple of
     pixels of headroom. */
  .gd-body {
    --gd-cat-strip-h: 64px;
  }

  .gd-categories.is-nested {
    display: flex;
    max-height: 0;
    overflow: hidden;
    transition: max-height 0.35s ease;
  }

  .gd-body:has(.gd-projects.is-open) .gd-categories.is-nested {
    max-height: var(--gd-cat-strip-h);
  }

  .gd-body:has(.gd-projects.is-open) .gd-projects {
    top: var(--gd-cat-strip-h);
  }

  /* ---- Graphic Design copy drawer: back to the right-side slide-in ---- */
  .cs2__drawer {
    top: 0;
    right: 0;
    bottom: 0;
    left: auto;
    width: min(460px, 90%);
    height: auto;
    flex-direction: row;
    transform: translateX(calc(100% - var(--cs2-tab-w)));
  }

  .cs2__drawer.is-open {
    transform: translateX(0);
  }

  .cs2__gallery {
    padding: var(--gap-sm) calc(var(--gap-sm) + var(--cs2-tab-w)) var(--gap-sm) var(--gap-sm);
  }

  .cs2__tab {
    flex: 0 0 var(--cs2-tab-w);
    flex-direction: column;
    /* The plain max-width:900px block above (the portrait bottom-drawer
       treatment) sets justify-content: space-between for its horizontal
       row layout — label at one end, icon at the other. This block resets
       flex-direction back to column for the landscape side drawer but
       never reset justify-content, so it stayed space-between: on a
       column axis that pushes the label to the very top and the icon to
       the very bottom instead of centering them together (confirmed on a
       real phone — the two ended up looking unrelated, ~150px apart). */
    justify-content: center;
    border-left: var(--border-hair);
    border-top: none;
  }

  .cs2__tab-label {
    writing-mode: vertical-rl;
  }

  .cs2__tab-icon {
    transform: rotate(180deg);
  }

  .cs2__drawer.is-open .cs2__tab-icon {
    transform: rotate(0deg);
  }

  .cs2__drawer-panel {
    box-shadow: -12px 0 28px -10px rgba(0, 0, 0, 0.65);
  }

  /* Reported from a real phone, 2026-09-14: with the browser's own
     status+address bar also eating real height (that chrome is
     permanent here — this app's outer document never scrolls, only
     .cs2__gallery does internally, so the browser has no scroll signal
     to ever shrink its own toolbar on this site), a landscape phone has
     barely enough room for the spread itself, let alone the spread +
     nav row + filmstrip all at once. Mike's call: drop the filmstrip
     here and let the nav row have the room instead — jumping to an
     arbitrary page by thumbnail matters less than prev/next reliably
     having space to render in. Portrait keeps the filmstrip; it isn't
     nearly as height-starved. */
  .gd-booklet__filmstrip {
    display: none;
  }
}

/* Everything below is phone-only (≤600px) — unrelated to the twin-drawer
   system above, which now also covers tablet widths. */
@media (max-width: 600px) {
  /* ---- home + graphic design tiles ------------------------------------ */
  /* No photo washes behind the tiles on small screens — at this size they
     fought the labels instead of supporting them. */

  .quadrant__photo,
  .gd-category__photo {
    display: none;
  }

  /* ---- about ---------------------------------------------------------- */
  /* Photo moves above the bio, capped so the bio still opens on screen. */

  /* Stacked, the three columns no longer share a row height, so the bio
     can't be its own scroll area — hand scrolling back to the body. */
  .about-body {
    grid-template-columns: 1fr;
    grid-auto-rows: auto;
    overflow-y: auto;
  }

  .about-contact {
    order: 1;
  }

  /* Needs a definite height: every child of .about-photo is absolutely
     positioned, so it has no intrinsic height of its own. Side by side on
     desktop the grid row borrows the bio's height; stacked, nothing does,
     and it collapses to its own borders. Capped so the bio still opens
     on screen. */
  .about-photo {
    order: 2;
    height: min(34vh, 260px);
  }

  /* min-height must go back to `auto` here. Stacked, the bio is an auto grid
     row, and `min-height: 0` (set on the desktop rule so the column can
     scroll) removes the item's automatic minimum size — which let the grid
     squash the row to fit the screen while the text kept its real height and
     ran straight out the bottom of its own border. */
  .about-dossier {
    order: 3;
    overflow-y: visible;
    min-height: auto;
  }
}

@media (prefers-reduced-motion: reduce) {
  .gd-projects,
  .m3d-list,
  .pres-list,
  .m3d-drawer,
  .cs2__drawer,
  .mdrawer-tab__icon,
  .cs2__tab-icon,
  .section-header,
  .cs2__header {
    transition: none;
  }

  /* 2.3.1: quadrant-glitch nudges translate() on hover/focus (landing +
     graphic-design category tiles); cs2-tab-blink flashes the pull-tab
     color a few times on first render. Neither is the tile-blink-in reveal
     above (that one stays — see the landmine comment near .will-reveal) but
     both are still motion triggered without user control, so kill them too. */
  .quadrant:hover,
  .quadrant:focus-visible,
  .gd-category:hover,
  .gd-category:focus-visible,
  .cs2__tab--blink {
    animation: none;
  }
}
