/* ============================================================
   TOKENS
   ============================================================ */
:root {
  --bg:           #0009FF;
  --bg-secondary: #0007D6;
  --fg:           #FFFFFF;
  --fg-muted:     #C7C7FF;
  --fg-dim:       #B8B8FF;
  --accent:       #FFFFFF;
  --border:       rgba(255,255,255,0.28);
  --border-light: rgba(255,255,255,0.16);

  --font-mono: 'IBM Plex Mono', 'Courier New', monospace;
  --font-display: 'Barlow Condensed', 'Arial Narrow', sans-serif;
  --font-body: 'Barlow', 'Helvetica Neue', Arial, sans-serif;

  --grid-gap:  1px;
  --page-pad:  24px;
  --nav-h:     84px;
}

/* ============================================================
   RESET
   ============================================================ */
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }

html { font-size: 13px; }

body {
  background: var(--bg);
  color: var(--fg);
  font-family: var(--font-mono);
  line-height: 1.5;
  min-height: 100vh;

}

a { color: inherit; text-decoration: none; }
img { display: block; max-width: 100%; }
ul { list-style: none; }

/* ============================================================
   LAYOUT
   ============================================================ */
.page-wrap {
  max-width: 1400px;
  margin: 0 auto;
  padding: 0 var(--page-pad);
}

/* ============================================================
   NAV
   ============================================================ */
nav.site-header {
  position: sticky;
  top: 0;
  z-index: 100;
  height: var(--nav-h);
  border-bottom: 1px solid var(--border-light);
  background: var(--bg);
  display: flex;
  align-items: stretch;
}

.nav-inner {
  display: flex;
  align-items: center;
  width: 100%;
  padding: 0 16px;
}

.nav-logo {
  display: flex;
  align-items: center;
  font-family: var(--font-display);
  font-size: 22px;
  font-weight: 800;
  letter-spacing: 0.01em;
  text-transform: uppercase;
  color: #FFFFFF;
}

.nav-logo .fn-name { color: #FFFFFF; }

/* Takes the wordmark's slot on a project page at mobile widths, matching its
   font and size so the nav's rhythm is unchanged. Borderless like the logo
   it replaces -- the arrow already reads as "back". */
.nav-back {
  display: none;
  align-items: center;
  gap: 10px;
  font-family: var(--font-display);
  font-size: 22px;
  font-weight: 800;
  letter-spacing: 0.01em;
  text-transform: uppercase;
  color: #FFFFFF;
}

.nav-back svg { width: 22px; height: 11px; }

/* Scoped to project pages: an unscoped `.nav-logo { display: none }` here
   wiped the wordmark off the work and about pages too. `.nav-back` only
   exists on project pages, but the logo does not, so the switch needs the
   body attribute rather than a sibling selector. */
@media (max-width: 800px) {
  body[data-view="project"] .nav-back { display: flex; }
  body[data-view="project"] .nav-logo { display: none; }
  /* the nav carries it now; two back links would be redundant */
  body[data-view="project"] .header-copy .back-link { display: none; }
}

.nav-meta {
  display: flex;
  flex-direction: column;
  justify-content: center;
  gap: 2px;
  /* measured off the logo's own text: the logo no longer carries trailing
     padding, so this margin is the whole gap */
  margin-left: 16px;
  font-family: var(--font-mono);
  font-size: 10px;
  line-height: 1.55;
  color: rgba(255,255,255,0.72);
  white-space: nowrap;
}

.nav-meta .nm-indent { padding-left: 22px; }
.nav-meta .nm-key    { color: #FFFFFF; }
.nav-meta .nm-punct  { color: rgba(255,255,255,0.45); }

@media (max-width: 1080px) {
  .nav-meta { display: none; }
}

.nav-links {
  display: flex;
  align-items: center;
  gap: 4px;
  margin-left: auto;
}

.nav-btn {
  display: flex;
  align-items: center;
  padding: 6px 12px;
  font-family: var(--font-display);
  font-size: 21px;
  font-weight: 700;
  letter-spacing: 0.02em;
  text-transform: uppercase;
  color: var(--fg-muted);
  transition: color 0.15s;
}

.nav-btn:hover { color: #FFFFFF; }

/* state is carried by colour now, so the dotted outline is gone */
.nav-btn.active { color: #FFFFFF; }

/* ============================================================
   PAGE HEADER (project detail pages)
   ============================================================ */
.page-header {
  position: relative;
  display: flex;
  justify-content: space-between;
  align-items: flex-end;
  gap: 48px;
  max-width: 1400px;
  margin: 0 auto;
  padding: 40px 0 32px;
}

.header-copy {
  /* grows into whatever the stats bar does not need, so the title and
     descriptor run out toward the right margin instead of sitting at
     their own content width */
  flex: 1 1 auto;
  min-width: 0;
}

@media (max-width: 1250px) {
  /* stack: the stats column reads as a bar under the copy, not beside it.
     Raised from 900px: the bar's cells cannot shrink, so side-by-side only
     works while ~1250px is available -- below that the title got crushed.
     align-items on the row layout is flex-end (baseline-matching the stats
     bar to the paragraph); once the axis turns vertical that same value
     means "flush right", which shoved the title off the left margin. */
  .page-header { flex-direction: column; align-items: stretch; gap: 24px; }
}


.page-header h1 {
  font-family: var(--font-display);
  font-size: clamp(48px, 7vw, 96px);
  font-weight: 800;
  text-transform: uppercase;
  letter-spacing: -0.01em;
  line-height: 0.95;
  color: var(--fg);
  /* cap height sits ~0.16em below the line box top in Barlow Condensed;
     this lifts it flush with the back tab's top border */
  margin-top: -0.16em;
  margin-bottom: 16px;
}

.page-header .descriptor {
  font-family: var(--font-body);
  font-size: 18px;
  color: var(--fg);
  /* uncapped, this paragraph's max-content width squeezes .project-meta-bar
     to nothing and its nowrap cells overflow the page. 800px matches the
     body prose measure; below 900px the header stacks and this goes unused. */
  max-width: 800px;
  line-height: 1.6;
}

/* ============================================================
   PROJECT GRID
   ============================================================ */
.project-grid {
  display: grid;
  grid-template-columns: repeat(2, 1fr);
  gap: var(--grid-gap);
}

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

/* ============================================================
   PROJECT CARD
   ============================================================ */
.project-card {
  position: relative;
  display: flex;
  flex-direction: column;
  background: var(--bg);
  border: 1px solid transparent;   /* width reserved so hover causes no layout shift */
  overflow: hidden;
  transition: border-color 0.25s;
}

.project-card:hover { border-color: var(--fg-dim); }

/* Corner registration marks */
.project-card::before,
.project-card::after {
  content: '+';
  position: absolute;
  font-family: var(--font-mono);
  font-size: 11px;
  color: var(--fg-dim);
  line-height: 1;
  z-index: 2;
  transition: color 0.25s;
  pointer-events: none;
}
.project-card::before { top: 8px; left: 10px; }
.project-card::after  { top: 8px; right: 10px; }
.project-card:hover::before,
.project-card:hover::after { color: var(--accent); }

.project-card a {
  display: flex;
  flex-direction: column;
  flex: 1;
}

.card-img {
  position: relative;
  overflow: hidden;
  aspect-ratio: 16 / 9;
  background: #000000;
}

.card-img img {
  width: 100%;
  height: 100%;
  object-fit: cover;
  transition: transform 0.6s ease;
}

.card-img--contain img {
  object-fit: contain;
}

/* Breathing room above and below contained art. The frame keeps its
   aspect-ratio height and the image gives up 64px.
   Flex, not the base `height: 100%`: a percentage height inside an
   aspect-ratio box does not reliably resolve against the content box, so it
   overflowed the padding on the hero. Stretch measures the content box. */
.card-img--inset {
  padding: 32px 0;
  display: flex;
}

.card-img--inset img {
  height: auto;
  min-height: 0;
  align-self: stretch;
}

.project-card:hover .card-img img {
  transform: scale(1.04);
}

.card-body {
  /* title and data strip share one row, strip pushed to the right edge */
  display: flex;
  align-items: stretch;
  justify-content: space-between;
  /* the strip drops under the title rather than squashing it on narrow cards */
  flex-wrap: wrap;
  /* No vertical padding and none on the right: the children carry their own,
     so the strip's hairlines can run the full height of the bar and the group
     can reach the card's own border, which becomes its right wall. */
  padding: 0 0 0 20px;
}

.card-title {
  /* may wrap or shrink; the strip never does */
  min-width: 0;
  align-self: center;
  /* the right padding is what separates the title from the strip */
  padding: 16px 20px 18px 0;
  font-family: var(--font-display);
  font-size: clamp(22px, 2.8vw, 38px);
  font-weight: 800;
  text-transform: uppercase;
  letter-spacing: -0.01em;
  line-height: 0.95;
  color: var(--fg);
  transition: color 0.25s;
}

.project-card:hover .card-title { color: var(--accent); }

/* Data strip — inline with the card title, an enclosed compartment on the
   right of the bar: hairlines run the full bar height, the leading one walls
   it off from the title, and the card's own border closes it on the right.
   Cells are the shared INFO ROW component (see the block below the meta bar
   further down this file) -- .card-strip only owns this wide/inline layout
   and its own breakpoint; the narrow-viewport variant rules live with the
   component itself. */
.card-strip {
  display: flex;
  align-items: stretch;
  /* holds its full width so the title is what gives way, and stays right even
     when it has wrapped onto a line of its own */
  flex: 0 0 auto;
  margin-left: auto;
}

.card-strip .info-cell {
  display: flex;
  flex-direction: column;
  justify-content: center;
  gap: 3px;
  /* matches the title's vertical padding, so the bar height is unchanged */
  padding: 16px 12px 18px;
  /* on every cell: gives the leading wall and the dividers in one rule, and
     leaves the trailing edge to the card border */
  border-left: 1px solid var(--border);
}

/* the last value keeps the same optical gutter the title has on the left */
.card-strip .info-cell:last-child { padding-right: 20px; }

/* --- phones: the data strip takes a full-width row of its own ---
   `.card-strip { margin-left: auto }` holds it hard right even once it has
   wrapped, so a long title (card 01) left ~176px of dead space beside the
   strip while a short one (card 02) kept the single-row body: four cards,
   two different layouts. Forcing the wrap on every card makes them
   consistent and turns the strip into a full data row. The row itself then
   picks a variant of the INFO ROW component -- see that block below for
   what --static and --carousel each do; only the breakpoint is card-specific. */
@media (max-width: 560px) {
  .card-title {
    flex: 1 0 100%;
    padding-right: 0;
    padding-bottom: 14px;
  }

  .card-strip {
    margin-left: 0;
    width: 100%;
    /* the strip is now a row rather than a group at the end of one */
    border-top: 1px solid var(--border-light);
  }

  .card-strip.info-row--static .info-cell {
    flex: 1 1 0;
    min-width: 0;
  }

  /* the border-left + padding-left on every cell is the wall that separates
     the strip from the title when they share a row on desktop; once the
     strip is its own row (this breakpoint, always), the first cell has
     nothing to its left to wall off, and that leftover 13px would knock
     TYPE out of alignment with the title above it. */
  .card-strip.info-row--static .info-cell:first-child,
  .card-strip.info-row--carousel .info-cell:first-child {
    padding-left: 0;
    border-left: none;
  }

  .card-strip.info-row--carousel {
    display: flex;
    flex-wrap: nowrap;
    overflow-x: auto;
    overscroll-behavior-x: contain;
    scroll-snap-type: x proximity;
    -webkit-overflow-scrolling: touch;
    scrollbar-width: none;
  }

  .card-strip.info-row--carousel::-webkit-scrollbar { display: none; }

  .card-strip.info-row--carousel .info-cell {
    padding: 14px 16px;
    min-width: min(44%, 160px);
    scroll-snap-align: start;
  }
}

/* ============================================================
   PLACEHOLDER IMAGE (when no real img)
   ============================================================ */
.img-placeholder {
  width: 100%;
  height: 100%;
  display: flex;
  align-items: center;
  justify-content: center;
  background: var(--bg-secondary);
  color: var(--fg-dim);
  font-size: 10px;
  letter-spacing: 0.12em;
  text-transform: uppercase;
}

/* ============================================================
   FOOTER
   ============================================================ */
footer {
  /* a rule above it so the footer reads as a band, not as trailing text */
  border-top: 1px solid var(--border-light);
  padding: 0 24px;
}

.footer-inner {
  display: flex;
  align-items: center;
  /* matches the page's own measure so it lines up with the content above */
  max-width: 1400px;
  min-height: 56px;
  margin: 0 auto;
  padding: 14px 0;
}

/* --- live ticker ---------------------------------------------------------
   Lives in the footer, right of the copyright. The footer is in normal
   document flow, so it only comes into view at the bottom of a page. The track holds two identical groups and slides
   exactly -50%, i.e. one group's width, so the seam never shows regardless
   of how much copy is in content/projects.json.
   ------------------------------------------------------------------------ */
.footer-ticker {
  /* takes the leftover width and is allowed to shrink to nothing, so it can
     never push the copyright or the page link out of the row */
  flex: 1 1 0;
  min-width: 0;
  margin: 0 28px;
  overflow: hidden;
  /* the copy has to enter and leave, not pop, so both edges fade out */
  mask-image: linear-gradient(to right, transparent, #000 24px,
              #000 calc(100% - 24px), transparent);
  -webkit-mask-image: linear-gradient(to right, transparent, #000 24px,
              #000 calc(100% - 24px), transparent);
}

.ticker-track {
  display: flex;
  width: max-content;
  animation: ticker-scroll var(--ticker-duration, 34s) linear infinite;
}

/* let the eye stop on an item */
.footer-ticker:hover .ticker-track { animation-play-state: paused; }

.ticker-group {
  display: flex;
  align-items: center;
  flex: 0 0 auto;
}

.ticker-item {
  font-family: var(--font-display);
  font-size: 15px;
  font-weight: 700;
  letter-spacing: 0.03em;
  text-transform: uppercase;
  white-space: nowrap;
  color: #FFFFFF;
}

.ticker-sep {
  padding: 0 14px;
  font-family: var(--font-display);
  font-size: 15px;
  font-weight: 700;
  color: rgba(255, 255, 255, 0.45);
}

@keyframes ticker-scroll {
  from { transform: translateX(0); }
  /* one copy's width; build.py sets the percentage from the copy count */
  to   { transform: translateX(var(--ticker-shift, -50%)); }
}

/* constant motion is exactly what this preference is about: park it */
@media (prefers-reduced-motion: reduce) {
  /* longhand on purpose: the `animation` shorthand serialises to `auto` here
     and stops reliably killing the name */
  .ticker-track { animation-name: none; }
}

/* the row runs out of room before the ticker is worth reading */
@media (max-width: 720px) {
  .footer-ticker { display: none; }
}

.footer-left {
  /* never squeezed by the ticker: the copyright always reads in full */
  flex: 0 0 auto;
  font-size: 10px;
  color: var(--fg-dim);
  letter-spacing: 0.08em;
}

.footer-right {
  flex: 0 0 auto;
  font-size: 10px;
  color: var(--fg-dim);
  letter-spacing: 0.08em;
}

/* ============================================================
   ABOUT PAGE
   ============================================================ */
.about-blocks {
  display: grid;
  grid-template-columns: 1fr 1fr;
  align-items: start;
  gap: var(--grid-gap);
}

.about-col {
  display: flex;
  flex-direction: column;
  gap: var(--grid-gap);
}

@media (max-width: 800px) {
  .about-blocks { grid-template-columns: 1fr; }
}

/* --- black section block --- */
.blk {
  /* Cooler, bluer greys inside the black blocks. #C7C7FF read violet
     because R==G with B pushed up; stepping R<G<B reads as slate. */
  --fg-muted: #AEBECF;
  --fg-dim:   #8A9BAE;
  background: #000000;
  padding: 32px;
}

.blk-title {
  font-family: var(--font-display);
  font-size: 34px;
  font-weight: 800;
  line-height: 1;
  color: #FFFFFF;
  margin-bottom: 24px;
}

.blk p {
  font-size: 13px;
  line-height: 1.9;
  color: #FFFFFF;
  margin-bottom: 20px;
}

/* the leading `//` stays muted so it reads as a comment marker, not as copy */
.blk p .p-comment { color: var(--fg-muted); }

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

/* --- portrait --- */
.about-portrait {
  width: 200px;
  aspect-ratio: 4 / 5;
  overflow: hidden;
  background: #111111;
  margin-bottom: 32px;
}

.about-portrait img {
  width: 100%;
  height: 100%;
  object-fit: cover;
  display: block;
}

.about-portrait .img-placeholder {
  height: 100%;
  display: flex;
  align-items: center;
  justify-content: center;
  font-size: 10px;
  letter-spacing: 0.12em;
  text-transform: uppercase;
  color: var(--fg-dim);
}

/* --- code block (experience / education) --- */
.code-block {
  font-family: var(--font-mono);
  font-size: 13px;
  line-height: 1.85;
  color: #FFFFFF;
}

.code-block .cb-brace { color: rgba(255,255,255,0.55); }

.cb-entry { margin: 0 0 20px; }
.cb-entry:last-of-type { margin-bottom: 0; }

.cb-entry .cb-org  { color: #FFFFFF; }
.cb-entry .cb-op   { color: rgba(255,255,255,0.55); }
.cb-entry .cb-role { color: #FFFFFF; }
.cb-entry .cb-team { color: var(--fg-muted); }
.cb-entry .cb-span { color: #FFFFFF; }

/* --- contact --- */
.contact-list {
  display: flex;
  flex-direction: column;
  gap: 0;
}

.contact-item {
  display: flex;
  flex-direction: column;
  gap: 3px;
  padding: 12px 0;
  border-bottom: 1px solid var(--border-light);
}

.contact-item:first-child { padding-top: 0; }
.contact-item:last-child { border-bottom: none; padding-bottom: 0; }

.contact-item .c-key {
  font-size: 9px;
  letter-spacing: 0.16em;
  text-transform: uppercase;
  color: var(--fg-dim);
}

.contact-item .c-val {
  font-size: 13px;
  color: #FFFFFF;
}

.contact-item .c-val a:hover { text-decoration: underline; }

/* --- colophon ---
   Spec-sheet rows: key hard left, value hard right, hairline between.
   Same language as the project card strip, so the site describes itself
   in the same voice it describes the work. */
.colophon-list {
  display: flex;
  flex-direction: column;
  gap: 0;
}

.colo-item {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: 16px;
  padding: 10px 0;
  border-bottom: 1px solid var(--border-light);
}

.colo-item:first-child { padding-top: 0; }
.colo-item:last-child  { border-bottom: none; padding-bottom: 0; }

.colo-key {
  flex: 0 0 auto;
  font-size: 9px;
  letter-spacing: 0.16em;
  text-transform: uppercase;
  color: var(--fg-dim);
}

.colo-val {
  flex: 1 1 auto;
  min-width: 0;
  text-align: right;
  font-size: 12px;
  color: #FFFFFF;
  font-variant-numeric: tabular-nums;
}

/* two columns cannot share a narrow line without the value wrapping oddly */
@media (max-width: 520px) {
  .colo-item { flex-direction: column; gap: 3px; }
  .colo-val  { text-align: left; }
}

/* --- skills --- */
.skill-list {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
  gap: 2px;
}

.skill-item {
  font-size: 12px;
  color: var(--fg-muted);
  padding: 8px 10px;
  border: 1px solid var(--border-light);
  display: flex;
  align-items: center;
  gap: 8px;
}

/* ============================================================
   PROJECT DETAIL PAGE
   ============================================================ */
.project-hero {
  max-width: 1400px;
  margin: 0 auto;
}

.project-hero-img {
  width: 100%;
  aspect-ratio: 21 / 9;
  overflow: hidden;
  border-bottom: 1px solid var(--border);
  background: #000000;
}

.project-hero-img img {
  width: 100%;
  height: 100%;
  object-fit: cover;
}

/* For art that is squarer than the frame: show it whole, let black letterbox */
.project-hero-img--contain img {
  object-fit: contain;
}

/* see .card-img--inset for why this is flex and not `height: 100%` */
.project-hero-img--inset {
  padding: 32px 0;
  display: flex;
}

.project-hero-img--inset img {
  height: auto;
  min-height: 0;
  align-self: stretch;
}

/* Horizontal strip of cells beside the title/descriptor, same cell
   language the bar used when it ran full-width under the hero. Fills
   whatever space is left of the paragraph, out to the hero's right edge,
   and sits flush with the paragraph's baseline (see align-items: flex-end
   on .page-header). */
.project-meta-bar {
  display: flex;
  flex-wrap: nowrap;
  gap: 0;
  /* the cells are `white-space: nowrap` and cannot shrink, so the bar takes
     its natural width and never flexes; .header-copy absorbs the rest.
     Below 1250px it goes full-width on its own row instead. */
  flex: 0 0 auto;
  align-content: flex-start;
  border-top: 1px solid var(--border);
  border-bottom: 1px solid var(--border);
}

@media (max-width: 1250px) {
  .project-meta-bar { width: 100%; flex: none; flex-wrap: wrap; }
}

.project-meta-bar .info-cell {
  display: flex;
  flex-direction: column;
  gap: 4px;
  padding: 16px 24px;
  border-right: 1px solid var(--border-light);
  flex: 0 0 auto;
  white-space: nowrap;
}

.project-meta-bar .info-cell:last-child { border-right: none; }
.project-meta-bar .info-cell:first-child { padding-left: 0; }

/* ============================================================
   INFO ROW — reusable key/value strip component
   Shared by this meta bar and the project card's data strip (see
   .card-strip further up). .info-key/.info-val are the single
   definition both point at; each wrapper still owns its OWN
   wide-viewport (inline, beside-the-title) layout and its own
   breakpoint for when that stops fitting. Past that point every
   consumer picks exactly one of two variants by class:

     .info-row--static     one full-width row of equal cells, each
                            free to wrap; nothing ever scrolls
     .info-row--carousel   horizontal scroll with snap, cells sized
                            so the next one is always cut mid-cell

   Plain CSS can't parameterize a media query's width, so the variant
   rule bodies are repeated once per consumer's own breakpoint -- but
   the declarations inside are kept identical wherever they appear;
   that repetition, not the class names, is the only thing per-usage.

   This bar is still comparing the two on a real device: default is
   carousel, ?bar=static swaps it (see js/main.js metaBarSwitch).
   Delete whichever variant loses, plus that switch, once decided. */
.info-key {
  font-size: 9px;
  letter-spacing: 0.16em;
  text-transform: uppercase;
  color: var(--fg-dim);
  font-family: var(--font-mono);
}

.info-val {
  font-size: 12px;
  color: var(--fg);
  font-family: var(--font-mono);
}

/* Below 760px the meta cells stop fitting on one line (they need ~713px of
   viewport), and a wrapped flex row is broken: each new row began at the
   second cell's 24px padding, so rows 2+ sat indented off the title's left
   margin, the vertical rules never lined up column to column, and whichever
   cell ended a row left a hairline pointing at nothing -- hence the variant
   system above. 760px is deliberate: above it the single-row bar fits, and
   is untouched. */
@media (max-width: 760px) {

  .project-meta-bar.info-row--carousel {
    display: flex;
    flex-wrap: nowrap;
    overflow-x: auto;
    /* without this, swiping the strip triggers Safari's back-swipe */
    overscroll-behavior-x: contain;
    scroll-snap-type: x proximity;
    -webkit-overflow-scrolling: touch;
    scrollbar-width: none;
  }

  .project-meta-bar.info-row--carousel::-webkit-scrollbar { display: none; }

  .project-meta-bar.info-row--carousel .info-cell {
    padding: 14px 16px;
    /* wide enough that the next cell is always cut through its middle
       rather than at a border: that cut is the only affordance a scroller
       like this gets, so it has to be unmistakable. Capped at 160px so wider
       phones do not inflate every cell and invent extra scroll distance. */
    min-width: min(44%, 160px);
    scroll-snap-align: start;
  }

  .project-meta-bar.info-row--carousel .info-cell:first-child { padding-left: 0; }

  .project-meta-bar.info-row--static {
    display: flex;
    flex-wrap: nowrap;
    width: 100%;
  }

  .project-meta-bar.info-row--static .info-cell {
    flex: 1 1 0;
    min-width: 0;
    white-space: normal;
    padding: 14px 16px;
    border-right: 1px solid var(--border-light);
  }

  .project-meta-bar.info-row--static .info-cell:first-child { padding-left: 0; }
}

.project-body {
  display: grid;
  grid-template-columns: 280px 1fr;
  max-width: 1400px;
  margin: 0 auto;
  border-bottom: 1px solid var(--border);
}

@media (max-width: 800px) {
  .project-body { grid-template-columns: 1fr; }
}

.project-sidebar {
  border-right: 1px solid var(--border);
  padding: 32px 24px 32px 0;
  display: flex;
  flex-direction: column;
  gap: 32px;
}

.project-content {
  /* left gutter clears the sidebar rule; right side runs out to the
     container edge so the prose is as wide as the hero above it */
  padding: 48px 0 48px 56px;
}

/* In-page section index. Sticky on the inner nav, not on .project-sidebar
   itself, so the sidebar's border-right still runs the full column height. */
.project-index {
  position: sticky;
  top: calc(var(--nav-h) + 32px);
}

.pindex-list {
  list-style: none;
}

.pindex-list li {
  border-bottom: 1px solid var(--border-light);
}

/* dividers between rows only, no rule above the first or below the last */
.pindex-list li:last-child { border-bottom: 0; }

.pindex-list a {
  display: flex;
  align-items: baseline;
  gap: 12px;
  padding: 9px 0;
  font-size: 11px;
  letter-spacing: 0.04em;
  text-transform: uppercase;
  color: var(--fg-muted);
  text-decoration: none;
  transition: color 0.2s;
}

/* No icon set on this site -- the back-arrow SVG is the only hand-drawn
   icon anywhere. This reuses the same "plain glyph" approach already used
   for the hover arrow below, rather than a mismatched one-off icon. */
.pindex-list a::before {
  content: "+";
  flex: none;
  width: 18px;
  font-size: 13px;
  color: var(--fg-dim);
}

.pindex-list a::after {
  content: "\2193";
  margin-left: auto;
  padding-left: 8px;
  font-size: 18px;
  color: var(--fg);
  opacity: 0;
  transform: translateY(-6px);
  transition: opacity 0.2s ease, transform 0.2s ease;
}

.pindex-list a:hover,
.pindex-list a:focus-visible { color: var(--fg); }

.pindex-list a:hover::after,
.pindex-list a:focus-visible::after {
  opacity: 1;
  transform: translateY(0);
}

/* anchor jumps must clear the sticky nav */
.project-content h2 { scroll-margin-top: calc(var(--nav-h) + 24px); }

@media (max-width: 800px) {
  /* single column: the index sits above the prose, nothing to stick to */
  .project-index { position: static; }
}

.project-content h2 {
  font-family: var(--font-display);
  font-size: 24px;
  font-weight: 800;
  line-height: 1;
  /* condensed caps need far less tracking than the 9px mono labels did */
  letter-spacing: 0.01em;
  text-transform: uppercase;
  color: var(--fg);
  margin-bottom: 16px;
  margin-top: 48px;
}

.project-content h2:first-child { margin-top: 0; }

.project-content p {
  font-family: var(--font-body);
  font-size: 18px;
  line-height: 1.6;
  /* full-width prose, but an unconstrained 1300px column is unreadable;
     800px is the widest that still holds a sane line length */
  max-width: 800px;
  color: var(--fg);
  margin-bottom: 20px;
}

/* Ordered body list — spec-sheet language borrowed from the card data strip:
   a hairline per row, the index hard-left in small mono. Baseline flex
   alignment keeps the 9px number sitting on the 13px text's first baseline
   without hardcoding a line-height. */
.project-content .body-list {
  list-style: none;
  counter-reset: bl;
  margin-bottom: 16px;
  border-top: 1px solid var(--border-light);
}

.project-content .body-list li {
  counter-increment: bl;
  display: flex;
  align-items: baseline;
  gap: 16px;
  padding: 14px 0;
  border-bottom: 1px solid var(--border-light);
  font-family: var(--font-body);
  font-size: 18px;
  line-height: 1.6;
  color: var(--fg);
}

.project-content .body-list li::before {
  content: counter(bl, decimal-leading-zero);
  flex: none;
  width: 28px;
  font-size: 16px;
  letter-spacing: 0.08em;
  color: var(--fg-dim);
}

.project-content .project-img-block {
  margin: 32px 0;
  border: 1px solid var(--border-light);
  overflow: hidden;
}

.project-content .project-img-block img {
  width: 100%;
  display: block;
}

.project-content .img-caption {
  font-family: var(--font-mono);
  font-size: 9px;
  letter-spacing: 0.1em;
  text-transform: uppercase;
  color: var(--fg-dim);
  padding: 8px 12px;
  border-top: 1px solid var(--border-light);
  background: var(--bg-secondary);
}

/* ============================================================
   UTILITIES
   ============================================================ */
.accent-text { color: var(--accent); }

/* Scanline / noise texture on accent elements */
.accent-fill {
  background: var(--accent);
  color: var(--bg);
}

.divider {
  width: 100%;
  height: 1px;
  background: var(--border);
  margin: 0;
}

/* ============================================================
   BACK LINK
   ============================================================ */
/* Sits in normal flow at the top of .header-copy, directly above the
   h1 -- so it scrolls away with the page rather than floating. The h1's
   -0.16em cap-height lift cancels this margin's optical slack, so the
   visual gap from border to glyph top is the margin-bottom exactly. */
.back-link {
  display: flex;
  width: fit-content;
  align-items: center;
  gap: 10px;
  padding: 10px 14px;
  margin-bottom: 20px;
  border: 1px dotted rgba(255,255,255,0.45);
  color: rgba(255,255,255,0.65);
  background: var(--bg);
  transition: color 0.2s, border-color 0.2s;
}

.back-link:hover {
  color: #FFFFFF;
  border-color: #FFFFFF;
}

.back-link svg {
  width: 22px;
  height: 11px;
}

.back-link .back-label {
  font-family: var(--font-mono);
  font-size: 16px;
  letter-spacing: 0.18em;
  text-transform: uppercase;
  line-height: 1;
}

/* ------------------------------------------------------------
   Narrow viewports: the 1400px container is flush to the
   window edge, so restore a gutter for text blocks.
   ------------------------------------------------------------ */
@media (max-width: 800px) {
  .page-header { padding-left: 16px; padding-right: 16px; }
  footer { padding-left: 16px; padding-right: 16px; }
  .project-sidebar { padding-left: 16px; }
  /* single column here, so there is no sidebar rule to clear -- the 56px
     gutter would eat 29% of a 390px screen */
  .project-content { padding: 40px 16px; }
}

/* ============================================================
   CURSOR — '+' registration mark
   A real DOM element, not the native `cursor:` property: engines
   silently ignore custom cursor images in too many situations.
   The native cursor is only hidden once JS has built the follower,
   so with JS off you still get a normal pointer.
   ============================================================ */
.has-plus-cursor .cursor-plus,
.has-plus-cursor .cursor-plus * {
  cursor: none;
}

#plus-cursor {
  position: fixed;
  top: 0;
  left: 0;
  width: 22px;
  height: 22px;
  z-index: 9999;
  pointer-events: none;
  opacity: 0;
  transform: translate3d(-100px, -100px, 0);
  transition: opacity 0.12s linear;
  will-change: transform;
}

#plus-cursor.is-visible { opacity: 1; }

/* two crisp bars rather than a glyph, so it never sits on a half pixel */
#plus-cursor::before,
#plus-cursor::after {
  content: '';
  position: absolute;
  background: #FFFFFF;
}

#plus-cursor::before {
  top: 50%;
  left: 0;
  width: 100%;
  height: 2px;
  margin-top: -1px;
}

#plus-cursor::after {
  left: 50%;
  top: 0;
  height: 100%;
  width: 2px;
  margin-left: -1px;
}

@media (prefers-reduced-motion: reduce) {
  #plus-cursor { transition: none; }
}

/* ============================================================
   CURSOR GLITCH (home page card images)
   ------------------------------------------------------------
   A small window that follows the pointer and tears the image
   into offset scanlines. Built by js/main.js on first hover:
   copies of the card image, each clipped to one thin band.
   Held at opacity 0 unless the pointer is actually moving, so a
   still cursor leaves the card completely untouched.
   ============================================================ */
.glitch-field {
  position: absolute;
  inset: 0;
  overflow: hidden;
  pointer-events: none;
  opacity: 0;
  transition: opacity 0.08s linear;
}

.glitch-band {
  position: absolute;
  top: 0;
  left: 0;
  overflow: hidden;
  will-change: transform;
}

/* .card-img img hands down a 0.6s transform transition for the hover
   zoom; these need to jump instantly, every frame. Same specificity as
   that rule, so this has to stay below it in the file.

   max-width/height:none is load-bearing. The global `img { max-width: 100% }`
   caps a clone to its BAND's width, not the card's, so the full-size copy
   silently rendered as a ~200px thumbnail and nothing lined up. max-width
   constrains regardless of the inline width, so it has to be turned off. */
.glitch-band img {
  position: absolute;
  top: 0;
  left: 0;
  max-width: none;
  max-height: none;
  transition: none;
}
