/* ============================================================
   The projects board — /contributions/.

   A card grid rather than the site's usual hairline list, and the exception
   is earned: every entry here has a cover worth looking at. A reader deciding
   whether to watch a two-minute gameplay video decides from the frame, not
   from the sentence next to it — so the image is the entry, and the copy is
   what confirms it.

   Two rules hold the grid together:

   1. Every card is the same shape. The covers arrive at four different aspect
      ratios (a 16:9 video still, a 2.5:1 store banner, a 1.9:1 screenshot),
      and a grid of differently-proportioned images reads as a page that did
      not know what it was doing. They are all cropped to one 2:1 box.
   2. Every card answers the same questions in the same order — what it is,
      the one number worth knowing, what was built, whose work it was, what it
      runs on. Down a column, the eye can compare like with like without
      re-reading a layout each time.
   ============================================================ */

/* ---------- the toolbar ----------
   Filters on the left, the running count on the right. The count is not
   decoration: when a filter cuts the grid from seven cards to two, the count
   is what says the rest were filtered rather than never there — the reader
   has no other way to tell a short list from a hidden one. */
.proj-toolbar{
  display:flex;
  align-items:center;
  justify-content:space-between;
  flex-wrap:wrap;
  gap:14px 20px;
  padding-bottom:20px;
  margin-bottom:24px;
  border-bottom:1px solid var(--line);
}
.proj-filters{
  display:flex;
  flex-wrap:wrap;
  gap:8px;
}

/* One is on at a time, and the pressed state is the whole affordance. Set in
   the site's label language (mono, uppercase, widely tracked) so the row reads
   as controls rather than as a second navigation. */
.proj-filter{
  font-family:var(--font-mono);
  font-size:var(--t-micro);
  letter-spacing:0.1em;
  text-transform:uppercase;
  color:var(--muted);
  background:transparent;
  border:1px solid var(--line);
  border-radius:7px;
  padding:8px 13px;
  cursor:pointer;
  -webkit-tap-highlight-color:transparent;
  touch-action:manipulation;
  transition:color var(--dur-1) var(--ease-out),
             border-color var(--dur-1) var(--ease-out),
             background var(--dur-1) var(--ease-out);
}
.proj-filter:hover{ color:var(--text); border-color:var(--muted); }
/* On is a filled state, not a brighter outline: at this size a border colour
   alone is not a difference a reader can see across a row of seven — and with
   one filter on at a time, that single lit button is the only thing on the
   page saying why five cards are missing. */
.proj-filter[aria-pressed="true"]{
  color:var(--role-nav);
  border-color:color-mix(in srgb, var(--role-nav) 45%, var(--line));
  background:color-mix(in srgb, var(--role-nav) 12%, transparent);
}
@supports not (background: color-mix(in srgb, red 10%, blue)){
  .proj-filter[aria-pressed="true"]{
    border-color:rgba(90,169,255,0.45);
    background:rgba(90,169,255,0.12);
  }
}

.proj-count{
  margin:0;
  font-size:var(--t-micro);
  letter-spacing:0.1em;
  color:var(--muted);
  font-variant-numeric:tabular-nums;
}

/* ---------- the grid ----------
   auto-fill, so the column count follows the viewport instead of stopping at
   a fixed cap — the same reasoning as the site's containers (tokens.css). The
   card has a comfortable width and a wide screen gets more of them, not
   wider ones. */
.proj-grid{
  display:grid;
  grid-template-columns:repeat(auto-fill, minmax(min(100%, 300px), 1fr));
  gap:22px;
  padding-bottom:8px;
}

.proj{
  display:flex;
  flex-direction:column;
  background:var(--surface);
  border:1px solid var(--line);
  border-radius:12px;
  /* the cover is flush to the card's edge, so the corners have to clip it */
  overflow:hidden;
  transition:border-color var(--dur-1) var(--ease-out);
}
.proj:hover{ border-color:var(--line-hover); }
/* Filtering hides cards with the `hidden` attribute, whose display:none comes
   from the UA stylesheet — and the `display:flex` above, being an author rule,
   beats it. Without this line a filtered card keeps its place in the grid
   while the count says it is gone. */
.proj[hidden]{ display:none; }

/* ---------- the cover ----------
   One box, one crop, whatever arrives. object-fit does the cropping so the
   image keeps its own proportions rather than being squashed to fit; the
   per-image object-position (set by render-contributions.js) is what keeps a
   store banner's logo end in frame instead of centring on its fine print. */
.proj-media{
  position:relative;
  display:block;
  width:100%;
  aspect-ratio:2 / 1;
  margin:0;
  padding:0;
  border:0;
  border-bottom:1px solid var(--line);
  background:var(--surface-2);
  overflow:hidden;
}
@supports not (aspect-ratio: 2 / 1){
  .proj-media{ height:170px; }
}
.proj-cover{
  display:block;
  width:100%;
  height:100%;
  object-fit:cover;
}

/* A cover that plays. The whole box is the button, so the target is the
   picture rather than the small glyph on it. */
.proj-media-video{
  cursor:pointer;
  -webkit-tap-highlight-color:transparent;
  touch-action:manipulation;
  color:var(--text);
}
.proj-play{
  position:absolute;
  left:50%;
  top:50%;
  transform:translate(-50%,-50%);
  display:flex;
  align-items:center;
  justify-content:center;
  width:52px;
  height:52px;
  padding-left:3px; /* optical centring: a triangle's mass sits left of centre */
  border-radius:50%;
  /* Flat, not frosted. A backdrop-filter here is re-rasterised on every scroll
     frame — seven of them over seven covers — and it showed as a smeared block
     mid-scroll on the first machine it was tried on. A solid scrim reads the
     same at 52px and costs nothing. */
  background:rgba(13,15,18,0.72);
  border:1px solid rgba(255,255,255,0.28);
  transition:background var(--dur-1) var(--ease-out),
             border-color var(--dur-1) var(--ease-out),
             transform var(--dur-2) var(--ease-spring);
}
.proj-media-video:hover .proj-play{
  background:rgba(13,15,18,0.88);
  border-color:rgba(255,255,255,0.5);
  transform:translate(-50%,-50%) scale(1.08);
}
/* The cover lifts slightly under the pointer so the whole box, not just the
   glyph, reads as the thing being pressed. */
.proj-media-video .proj-cover{
  transition:transform var(--dur-3) var(--ease-out), opacity var(--dur-1) var(--ease-out);
}
.proj-media-video:hover .proj-cover{ transform:scale(1.03); opacity:0.9; }

/* ---------- the player ----------
   A lightbox, not an inline swap. The card's media box is 330px wide, which is
   the right size for a thumbnail and useless for actually watching anything,
   so the video opens over the page at whatever size the viewport will give it.

   Three ceilings on the width, and the last is the one that matters: a 16:9
   box sized only by width overflows a short window, and a laptop in landscape
   is exactly that. Deriving the width from the available *height* keeps the
   whole player on screen without the dialog needing to scroll. */
.video-modal{
  width:min(1180px, calc(100vw - 40px), calc((100vh - 132px) * 16 / 9));
  max-width:none;
  padding:0;
  border:0;
  /* The surface is the video. A panel around it would be a frame around a
     frame, and it is already sitting on a dimmed page. */
  background:transparent;
  color:var(--text);
  overflow:visible;
}
.video-modal[open]{ display:block; }
.video-modal::backdrop{
  background:rgba(6,7,9,0.86);
  backdrop-filter:blur(4px);
  -webkit-backdrop-filter:blur(4px);
}

/* In and out along the same path, mirrored curves — the same movement the
   contact popup uses, and for the same reason: a surface that animates in and
   then vanishes on close reads as a glitch rather than a dismissal. */
.video-modal[open]{ animation:video-modal-in var(--dur-2) var(--ease-out); }
.video-modal[open]::backdrop{ animation:video-backdrop-in var(--dur-2) var(--ease-out); }
@keyframes video-modal-in{
  from{ opacity:0; transform:translateY(10px) scale(.98); }
}
@keyframes video-backdrop-in{ from{ opacity:0; } }

/* `.is-closing` is set by render-contributions.js, which waits for these to
   end before it closes the dialog and destroys the iframe. `forwards` holds
   the last frame so nothing snaps back for the frame in between. */
.video-modal[open].is-closing{
  animation:video-modal-out 160ms var(--ease-in) forwards;
}
.video-modal[open].is-closing::backdrop{
  animation:video-backdrop-out 160ms var(--ease-in) forwards;
}
@keyframes video-modal-out{
  to{ opacity:0; transform:translateY(10px) scale(.98); }
}
@keyframes video-backdrop-out{ to{ opacity:0; } }

.video-modal-head{
  display:flex;
  align-items:center;
  justify-content:space-between;
  gap:16px;
  margin-bottom:10px;
}
.video-modal-title{
  margin:0;
  font-size:var(--t-mini);
  letter-spacing:0.08em;
  color:var(--muted);
  /* the title of a card, on one line, however long the card's title is */
  overflow:hidden;
  text-overflow:ellipsis;
  white-space:nowrap;
}
.video-close{
  flex:none;
  display:grid;
  place-items:center;
  width:34px;
  height:34px;
  border-radius:8px;
  border:1px solid var(--line);
  background:transparent;
  color:var(--muted);
  cursor:pointer;
  transition:color var(--dur-1) var(--ease-out),
             border-color var(--dur-1) var(--ease-out),
             background var(--dur-1) var(--ease-out);
}
.video-close svg{ width:16px; height:16px; display:block; }
.video-close:hover{ color:var(--text); border-color:var(--muted); background:var(--surface-2); }

.video-frame{
  position:relative;
  width:100%;
  aspect-ratio:16 / 9;
  border-radius:12px;
  overflow:hidden;
  background:#000;
  box-shadow:0 32px 90px rgba(0,0,0,0.6);
}
@supports not (aspect-ratio: 16 / 9){
  .video-frame{ height:0; padding-bottom:56.25%; }
}
.video-frame iframe{
  position:absolute;
  inset:0;
  display:block;
  width:100%;
  height:100%;
  border:0;
}

/* ---------- links on the cover ----------
   A still cover does nothing when clicked, so it carries the card's links —
   as full-height panels dividing the image between them rather than as pills
   sitting on it. A pill has to be aimed at; a panel is the half of the picture
   you are already pointing at, which is the whole reason the links moved onto
   the image in the first place.

   Written for no-hover first — a bar immediately under the image, always
   visible — and only lifted onto the cover in the block below. That order
   matters: a hover-revealed control is invisible until a device proves it can
   hover, so the visible fallback has to be the default rather than the
   exception.

   And the proof is three conditions, not one. `hover: hover` alone was tried
   and it failed on a real phone, which reported itself hoverable and hid the
   only route to the App Store behind a gesture it cannot perform — a browser
   is free to be wrong about this and several are. The viewport width cannot
   be wrong in the same way: below the breakpoint the card is the width of the
   screen and the buttons stay in the open, whatever the device claims about
   its pointer.

   The wrapper is what the overlay is positioned against. The media box is a
   fixed 2:1 frame that clips its own overflow, and these buttons have to be
   able to sit outside it. */
.proj-shot{ position:relative; }
.proj-actions{
  display:flex;
  /* No gap and no side padding: the bar is the width of the image, and the
     buttons divide it. A gap here would leave slivers of card showing between
     panels that are meant to read as one control. */
  border-bottom:1px solid var(--line);
}
/* Equal shares of the width, whatever the labels are — two buttons of
   different lengths splitting an image unevenly reads as a mistake. */
.proj-actions .proj-link{
  flex:1;
  justify-content:center;
  border:0;
  border-radius:0;
  /* 14px vertical puts the bar at 45px — a thumb's width, measured rather
     than assumed: at 13px the label's own 17px line box left it one pixel
     short. It has no effect in the overlay below, where the panels are
     stretched to the full height of the cover anyway. */
  padding:14px 10px;
  background:var(--surface-2);
}
.proj-actions .proj-link + .proj-link{ border-left:1px solid var(--line); }
.proj-actions .proj-link:hover{ background:#20242c; }

@media (hover: hover) and (pointer: fine) and (min-width: 721px){
  .proj-actions{
    position:absolute;
    inset:0;
    border-bottom:0;
    opacity:0;
    pointer-events:none;
    transition:opacity var(--dur-2) var(--ease-out);
  }
  /* focus-within is not a nicety here: without it the links exist only for
     people using a pointer. Opacity alone hides them — they stay in the tab
     order and in the accessibility tree — so tabbing to one brings the whole
     overlay up with it. */
  .proj-shot:hover .proj-actions,
  .proj-actions:focus-within{
    opacity:1;
    pointer-events:auto;
  }

  /* Black rather than the page's near-black, and translucent: the cover has to
     stay legible under it, or the reader loses track of which card they are
     on. The pointed-at panel deepens, which is what tells you which half you
     are about to commit to when both are lit. */
  .proj-actions .proj-link{
    background:rgba(0,0,0,0.55);
    color:#fff;
    font-size:var(--t-small);
  }
  .proj-actions .proj-link + .proj-link{
    border-left:1px solid rgba(255,255,255,0.18);
  }
  .proj-actions .proj-link:hover{
    background:rgba(0,0,0,0.76);
    color:var(--link-accent);
  }
  .proj-actions .proj-link-arrow{ color:inherit; }
}

/* ---------- the copy ----------
   A column, with the links pinned to the bottom by the auto margin below —
   cards in a row hold different amounts of text, and a link row that floats
   at a different height in each one turns a tidy grid into a ragged one. */
.proj-body{
  display:flex;
  flex-direction:column;
  flex:1;
  padding:18px 18px 20px;
}

.proj-head{
  display:flex;
  align-items:flex-start;
  justify-content:space-between;
  gap:10px;
  margin-bottom:5px;
}
.proj-title{
  font-size:var(--t-h4);
  line-height:1.25;
  letter-spacing:-0.008em;
  margin:0;
}

/* The badge says what state the thing is in, and every value is provable by a
   link on the same card — "Live" next to a store link, "Open source" next to
   a repository. Both are the same kind of fact, which is why both are now
   green: this thing exists and here is where to go and see it. They were two
   colours, which said the two statuses differed in kind when they differ only
   in wording — and it cost the amber its one real job elsewhere. The words
   still carry the distinction, so nothing is lost by not repeating it in hue.
   A status with no link to back it stays plain. */
/* This was set at 9px — below the floor for anything meant to be read, and on
   the surface that matters most, since a phone is where most of this audience
   arrives. It is on the scale's smallest step now (11px), with the tracking
   pulled back from 0.11em to 0.08em so the pill does not grow by the whole
   difference and start crowding the title beside it. Wider tracking is what a
   line of capitals needs at 9px; at 11px it needs less of it. */
.proj-status{
  flex:none;
  font-size:var(--t-micro);
  letter-spacing:0.08em;
  text-transform:uppercase;
  white-space:nowrap;
  border-radius:999px;
  padding:3px 8px;
  border:1px solid var(--line);
  color:var(--muted);
  /* the title beside it is a size larger; this sits on its cap height */
  margin-top:2px;
}
.proj-status.is-live,
.proj-status.is-open{
  color:var(--role-proof);
  border-color:color-mix(in srgb, var(--role-proof) 45%, transparent);
  background:color-mix(in srgb, var(--role-proof) 12%, transparent);
}
@supports not (background: color-mix(in srgb, red 10%, blue)){
  .proj-status.is-live,
  .proj-status.is-open{ border-color:rgba(70,167,88,0.45); background:rgba(70,167,88,0.12); }
}

.proj-kind{
  margin:0 0 10px;
  font-size:var(--t-mini);
  line-height:1.45;
  color:var(--muted);
}

/* The one figure worth reading from across the room, and the only line in the
   card set in the accent. There is at most one per card on purpose: a card
   with three highlighted numbers has highlighted none of them.

   It is the claim colour, not the link colour it used to wear. This line is
   the assertion the card exists to make — "81,521 physics props in one
   scene" — and the badge above it and the links below it are in green
   because they are what makes the assertion checkable. Scanned down the
   grid, every card reads red claim, green proof, in that order, which is the
   editorial rule the whole site is built on finally drawn rather than
   described. Nothing on this site is an error state, so red is free to mean
   the thing red should mean here. */
.proj-stat{
  margin:0 0 10px;
  font-family:var(--font-mono);
  font-size:var(--t-mini);
  line-height:1.45;
  color:var(--role-claim-ink);
  font-variant-numeric:tabular-nums;
}

.proj-desc{
  margin:0 0 12px;
  font-size:var(--t-small);
  line-height:1.6;
  color:var(--ink-3);
}
.proj-role{
  margin:0 0 12px;
  font-size:var(--t-mini);
  line-height:1.45;
  color:var(--muted);
}

/* ---------- the chips ----------
   Two weights, one row. Platforms are what a reader is checking for, so they
   are drawn as filled tiles; the tech behind them is context and recedes to an
   outline. Both are the same size — the difference is contrast, not scale, so
   the row keeps one rhythm. */
.proj-chips{
  display:flex;
  flex-wrap:wrap;
  gap:6px;
  list-style:none;
  margin:0 0 14px;
  padding:0;
}
.proj-chip{
  font-size:var(--t-micro);
  line-height:1.4;
  padding:3px 8px;
  border-radius:6px;
  background:var(--surface-2);
  border:1px solid var(--line);
  color:var(--text);
}
.proj-chip.is-tech{
  background:transparent;
  color:var(--muted);
}

/* ---------- the links ----------
   The shape a link takes on this page is set by the block above, which lays
   these out across the cover. What is defined here is the link itself — an
   outlined pill rather than a .btn, because a button says "this is the action
   of the page" and these are references.

   The arrow is the direction, matching the figures on the landing page: ↗
   leaves the site, → stays on it.

   .proj-links is the other home for them: a row at the foot of the card, used
   when the cover is a video and therefore already spoken for. No project needs
   it today — the video cards carry no links, since their cover plays the thing
   a link would have pointed at — but a Steam page added to one tomorrow has
   somewhere to land other than on top of a play button. */
.proj-links{
  display:flex;
  flex-wrap:wrap;
  gap:8px;
  /* pins the row to the bottom of a card of any height */
  margin-top:auto;
}
.proj-link{
  /* One accent, not four. Every link on this page leaves the site, and every
     destination it leaves for — a store listing, a repository, a video, a
     write-up — is the evidence behind the claim two lines above it. They were
     tinted per destination, which turned a row of proofs into a row of brand
     badges and spent three hues saying something the label already said.
     The value is lifted off the token because these sit over a cover image at
     0.75rem, where the true axis green is legible but not comfortable. */
  --link-accent:var(--role-proof-hi);
  display:inline-flex;
  align-items:center;
  gap:6px;
  padding:6px 12px;
  border:1px solid var(--line);
  border-radius:999px;
  font-size:var(--t-mini);
  line-height:1.4;
  color:var(--text);
  text-decoration:none;
  -webkit-tap-highlight-color:transparent;
  transition:color var(--dur-1) var(--ease-out),
             border-color var(--dur-1) var(--ease-out),
             background var(--dur-1) var(--ease-out);
}

/* The accent shows on hover only. Tinted at rest, a row of five pills reads as
   a set of badges to look at rather than a set of places to go — and the cards
   already carry the one coloured line each that they are allowed. */
.proj-link:hover{
  color:var(--link-accent);
  border-color:color-mix(in srgb, var(--link-accent) 55%, var(--line));
  background:rgba(255,255,255,0.03);
}
@supports not (background: color-mix(in srgb, red 10%, blue)){
  .proj-link:hover{ border-color:var(--muted); }
}
.proj-link:active{ background:rgba(255,255,255,0.06); }

/* Inline-block because a transform does nothing to an inline box — same as
   the landing page's cue arrow. */
.proj-link-arrow{
  display:inline-block;
  font-size:0.875em;
  color:var(--muted);
  transition:transform var(--dur-1) var(--ease-out),
             color var(--dur-1) var(--ease-out);
}
.proj-link:hover .proj-link-arrow{
  color:inherit;
  transform:translateX(3px);
}
/* ↗ leads away from the page, so its step is diagonal — the same distance,
   pointed where the link actually goes. Same rule as the landing page's. */
.proj-link[target="_blank"]:hover .proj-link-arrow{ transform:translate(2px,-2px); }

/* Only ever seen with scripts off, where the board itself is absent. */
.proj-noscript{
  color:var(--muted);
  font-size:var(--t-base);
  max-width:60ch;
  margin:0;
}
.proj-noscript a{ color:var(--role-nav); }

/* ---------- responsive ---------- */
@media (max-width: 720px){
  .proj-toolbar{
    /* The count moves under the filters rather than fighting them for the
       last 40px of a phone's width. */
    align-items:flex-start;
    flex-direction:column;
    gap:12px;
  }
  .proj-grid{ gap:18px; }
  .proj-body{ padding:16px 16px 18px; }

  /* ---------- the filters, as a rail ----------
     Seven chips wrapped to three rows put 168px of controls between the page
     heading and the first project — on the screen with the least height to
     spare, the reader arrives at a filter panel rather than at the work. One
     row that scrolls costs 44px and shows the same seven.

     Bleeding to the viewport edges is what makes it read as a rail rather
     than as a clipped row: chips run out of the screen on both sides instead
     of stopping short of a margin, which is the thing that says "there is
     more this way". The negative margin cancels .wrap's gutter and the
     padding puts it back inside, so the first chip still lines up with the
     heading above it. */
  .proj-filters{
    /* The row's width is stated rather than stretched. The toolbar is a column
       flex here, and a `nowrap` flex row inside it resolves its own width from
       its contents — seven chips, 768px of them — which stretch does not
       override and `min-width:0` does not reach. Left like that the rail is
       not a scroller at all: it is a very wide element that widens the whole
       document, and the page scrolls sideways instead of the chips.
       100% is the toolbar's content width; the two gutters are what the
       negative margins below give back. */
    width:calc(100% + var(--pad-l) + var(--pad-r));
    align-self:stretch;
    min-width:0;
    flex-wrap:nowrap;
    overflow-x:auto;
    scroll-snap-type:x proximity;
    /* Snapping measures from the scrollport's edge, not from the padding
       inside it — so on load the browser slid the rail by exactly one gutter
       to bring "All" flush to the screen edge, and the alignment the padding
       was there to create was gone before anyone touched it. This tells it
       where the content actually starts. */
    scroll-padding-inline:var(--pad-l) var(--pad-r);
    -webkit-overflow-scrolling:touch;
    /* the rail's own scrollbar is noise on a control this size; the fade at
       each end is the affordance instead */
    scrollbar-width:none;
    /* --pad-l/--pad-r are .wrap's resolved gutters (base.css). Cancelling and
       restoring exactly those is what keeps the first chip on the same
       vertical as the heading above it while the row itself runs to the
       screen edges. */
    margin-inline:calc(-1 * var(--pad-l)) calc(-1 * var(--pad-r));
    padding-inline:var(--pad-l) var(--pad-r);
    /* room for the chips' focus rings, which are drawn outside the box and
       would otherwise be clipped by the scroller */
    padding-block:4px;
    /* Fades to nothing at both edges, so a chip leaving the screen dissolves
       rather than being cut off mid-word — which is the thing that says
       "there is more this way" without a control saying it. */
    -webkit-mask-image:linear-gradient(90deg,
      transparent 0, #000 var(--pad-l), #000 calc(100% - var(--pad-r)), transparent 100%);
    mask-image:linear-gradient(90deg,
      transparent 0, #000 var(--pad-l), #000 calc(100% - var(--pad-r)), transparent 100%);
  }
  .proj-filters::-webkit-scrollbar{ display:none; }
  .proj-filter{
    flex:none;
    scroll-snap-align:start;
    /* 44px: hit with a thumb here, not pointed at. The text stays the size it
       was — this is the target growing, not the label. */
    min-height:44px;
    padding:9px 14px;
  }

  /* The player takes the width it can get here — a phone has none to spare —
     and the head above it tightens to match. */
  .video-modal{ width:min(calc(100vw - 24px), calc((100vh - 116px) * 16 / 9)); }
  .video-modal-head{ margin-bottom:8px; }
  .video-frame{ border-radius:10px; }
  /* Taller pills, because here they are hit with a thumb rather than pointed
     at. The text stays the same size — this is padding, not scale. */
  .proj-link{ padding:11px 14px; }
}
