  html, body { margin: 0; padding: 0; background: #161616; }
  body { display: flex; justify-content: center; overflow-x: auto; position: relative; }
  a { color: inherit; text-decoration: none; }

  * { box-sizing: border-box; margin: 0; padding: 0; }
  .c { width: 1440px; min-width: 0; min-height: 100vh; color: #f2f2f0; font-family: "Plus Jakarta Sans", -apple-system, "Helvetica Neue", Arial, sans-serif; position: relative; overflow: hidden; }

  .blob-layer { position: fixed; top: 0; left: 0; width: 100%; overflow: hidden; pointer-events: none; z-index: 0; }
  .blob { position: absolute; border-radius: 50%; opacity: 0.22; }
  /* Blobs live in .blob-layer, a direct child of body (not .c) so their glow
     reaches the true page edges on a very wide window instead of stopping at
     .c's 1440px column; .c's own background above is removed so the glow
     shows through the content column too, not just the margins beside it.
     .blob-layer is position:fixed (like js/particles.js's dot canvas) rather
     than absolutely positioned against body: js/particles.js sizes its
     height and drives its scroll parallax in lockstep with the canvas, so
     the blobs move at the same slower-than-content speed as the dots
     instead of scrolling at normal speed with the rest of the page. Being
     fixed also means .blob-layer's height (however tall) never counts
     toward the document's scrollable length the way it would if it were
     position:absolute -- which is what previously required JS to re-pin
     that height on every filter change just to stop the page itself from
     being held open at the wrong length. overflow:hidden here clips each
     blob's radial-gradient bleed right at the fixed box's own edge. */
  /* While a filter change is easing the footer into its new spot, .c's own
     height has already snapped to the new, shorter value -- so the footer
     briefly renders past .c's new bottom edge mid-ease. Lifting the clip
     just for that window stops it from getting cut off; it's restored once
     things settle. */
  /* .c is a flex item of body with no explicit min-width, so by default its
     automatic minimum width tracks its overflow value: overflow:hidden lets it
     shrink-to-fit narrower windows (min-width resolves to 0), but overflow:
     visible makes that resolve to its full 1440px instead -- which, without the
     min-width:0 above, briefly un-shrinks .c back to 1440px (pushing it off-
     center) for exactly the window .is-filtering is applied. min-width:0 makes
     the shrink-to-fit behavior permanent regardless of overflow. */
  .c.is-filtering { overflow: visible; }

  .nav { display: flex; align-items: center; justify-content: space-between; padding: 34px 56px; position: relative; z-index: 2; border-bottom: 1px solid rgba(255,255,255,0.08); }
  .nav .logo { font-size: 16px; font-weight: 800; letter-spacing: -0.01em; color: #fff; transition: opacity .2s ease; }
  .nav .logo:hover { opacity: 0.8; }
  .nav .logo span { color: #00d4ff; }
  .nav .links { display: flex; gap: 30px; font-size: 13px; font-weight: 600; color: rgba(255,255,255,0.7); }
  .nav .links a { transition: color .2s ease; }
  .nav .links a:hover { color: #fff; }
  .nav .links .current { color: #fff; }
  html[data-pw-mode="fade"] .nav .links a, html[data-pw-mode="fade"] .nav .links .current { transition: color .6s ease; }
  html[data-pw-mode="fade"] .nav .links .current { color: rgba(255,255,255,0.7); }
  html[data-pw-mode="fade"][data-pw-revealed="1"] .nav .links .current { color: #fff; }
  @media (prefers-reduced-motion: reduce) {
    html[data-pw-mode="fade"] .nav .links .current { transition: none !important; color: #fff !important; }
  }

  .hero { padding: 34px 56px 60px; max-width: 900px; position: relative; z-index: 2; }
  .hero .eyebrow { display: block; margin-bottom: 14px; font-size: 12px; font-weight: 800; letter-spacing: 0.12em; text-transform: uppercase; color: #0088ff; }
  .hero h1 { font-family: "Chivo", -apple-system, sans-serif; font-size: 58px; line-height: 1.08; font-weight: 900; letter-spacing: -0.02em; color: #fff; }
  .hero h1 .hl { color: #0088ff; }
  .hero p { margin-top: 22px; font-size: 17px; line-height: 1.6; color: #9a9a9a; max-width: 560px; }

  /* CSS multi-column (column-count) used to lay these out, but Safari has
     deep, long-standing bugs when a column-fragmented item contains a
     transform that changes (hover, drift, the filter FLIP slide) -- cards
     would flicker, render blank until hover, or even jump into a different
     column entirely. Three explicit flex columns sidestep that whole bug
     class (there's no column fragmentation happening at all), at the cost
     of the column split being fixed in the HTML (see .masonry-col) rather
     than auto-rebalanced -- it was hand-distributed to be close to even. */
  .masonry { display: flex; align-items: flex-start; gap: 22px; padding: 0 56px; position: relative; z-index: 2; }
  .masonry-col { display: flex; flex-direction: column; gap: 22px; flex: 1 1 0; min-width: 0; }
  /* .card itself is the item CSS multi-column fragments (break-inside:
     avoid) -- it stays a plain, untransformed box on purpose. Safari has a
     documented bug where a 3D transform on a column-fragmented item throws
     off where the next column starts, which is what was pushing the
     right-hand column down. All the animated stuff (drift, hover, the FLIP
     slide) lives on .card-visual instead, one level in, so it gets its own
     persistent GPU layer without .card itself ever being transformed. */
  .card { --dx: 0px; --dy: 0px; --s: 1; --lift: 0px; position: relative; opacity: var(--fo, 1); }
  .card:hover { z-index: 3; }
  .card-visual { display: block; background: #1e1e1e; border: 1px solid rgba(255,255,255,0.07); border-radius: 20px; overflow: hidden; box-shadow: 0 8px 24px -10px rgba(0,0,0,0.6); transform: translate3d(calc(var(--dx) + var(--flip-x, 0px)), calc(var(--dy) + var(--lift) + var(--flip-y, 0px)), 0) scale(calc(var(--s) * var(--fs, 1))); transition: box-shadow .28s ease, border-color .28s ease; will-change: transform; -webkit-backface-visibility: hidden; backface-visibility: hidden; }
  .card:hover .card-visual { box-shadow: 0 24px 48px -14px rgba(0,136,255,0.35); border-color: rgba(0,212,255,0.35); }
  .card .imgbox { position: relative; overflow: hidden; }
  .card .imgbox img { width: 100%; height: 100%; display: block; object-fit: cover; transition: transform .4s ease; filter: saturate(0.94); transform: scale(1) translateZ(0); -webkit-backface-visibility: hidden; backface-visibility: hidden; }
  .card:hover img { transform: scale(1.06) translateZ(0); }
  /* .thumb-hover is a same-name .gif dropped into images/hover-gifs/ (see
     the hover-gifs README) that crossfades in over the static .thumb on
     hover; onerror="this.remove()" on the element itself means a project
     with no gif yet just silently keeps showing the static thumbnail. */
  .card .imgbox .thumb-hover { position: absolute; inset: 0; opacity: 0; transition: opacity .35s ease, transform .4s ease; }
  .card:hover .imgbox .thumb-hover { opacity: 1; }
  .card .tags { position: absolute; top: 14px; left: 14px; display: flex; flex-wrap: wrap; gap: 6px; max-width: calc(100% - 28px); z-index: 2; }
  .card .tag { background: rgba(20,20,20,0.75); backdrop-filter: blur(6px); padding: 6px 12px; border-radius: 999px; font-size: 11px; font-weight: 700; letter-spacing: 0.02em; box-shadow: 0 4px 12px -4px rgba(0,0,0,0.4); }
  .tag.blue { color: #2e90ff; } .tag.cyan { color: #00d4ff; } .tag.violet { color: #8b7bff; } .tag.coral { color: #ff6b6b; } .tag.amber { color: #ffb648; } .tag.teal { color: #34d399; } .tag.pink { color: #f472b6; }
  /* Titles are visible by default (mobile-first) -- hiding them behind a
     hover state only makes sense for a device that can actually hover.
     Below, `hover:hover` + `pointer:fine` specifically detects a mouse/
     trackpad as the PRIMARY input (a plain touchscreen always reports
     hover:none/pointer:coarse regardless of screen size), and the
     min-width:641px on top means a narrow *desktop* window (already the
     same breakpoint the grid itself collapses at) also keeps titles
     persistent -- smaller cards there are harder to read via a fleeting
     hover overlay anyway. Any browser that doesn't support these media
     features at all just never matches the query, so it fails toward
     "always visible" rather than "permanently hidden". */
  .card .scrim { position: absolute; left: 0; right: 0; bottom: 0; padding: 46px 20px 20px; background: linear-gradient(180deg, rgba(0,0,0,0) 0%, rgba(0,0,0,0.85) 88%); opacity: 1; transform: translateY(0) translateZ(0); transition: opacity .3s ease, transform .3s ease; -webkit-backface-visibility: hidden; backface-visibility: hidden; }
  @media (min-width: 641px) and (hover: hover) and (pointer: fine) {
    .card .scrim { opacity: 0; transform: translateY(8px) translateZ(0); }
    .card:hover .scrim, .card:focus-visible .scrim { opacity: 1; transform: translateY(0) translateZ(0); }
  }
  .card .scrim h3 { color: #fff; font-size: 24px; font-weight: 800; letter-spacing: -0.01em; line-height: 1.15; }
  .card.is-hidden { display: none; }

  .filters { display: flex; flex-wrap: wrap; gap: 10px; padding: 0 56px 34px; position: relative; z-index: 2; }
  .filter-btn { appearance: none; cursor: pointer; border: 1.5px solid rgba(255,255,255,0.14); background: #1e1e1e; color: #f2f2f0; padding: 9px 18px; border-radius: 999px; font-size: 13px; font-weight: 700; letter-spacing: 0.01em; font-family: inherit; transition: border-color .15s ease, background .15s ease, color .15s ease; }
  .filter-btn:hover { border-color: var(--fc, #f2f2f0); }
  .filter-btn.active { background: var(--fc, #0088ff); border-color: var(--fc, #0088ff); color: #0c0c0c; }

  /* A thin divider line, matching the .nav border-bottom above -- this
     used to be a scrolling blue marquee bar of category names; kept as
     the same .marquee element/class (still a FLIP trail element, see
     trailEls in homepage.js) so it still eases into place in sync with
     the footer during filter transitions, just styled as a line now. */
  .marquee { margin: 60px 56px 0; height: 1px; background: rgba(255,255,255,0.08); position: relative; z-index: 2; transform: translateY(var(--flip-y, 0px)); will-change: transform; }

  .footer { display: flex; justify-content: space-between; align-items: center; padding: 50px 56px; position: relative; z-index: 2; border-top: 1px solid rgba(255,255,255,0.08); transform: translateY(var(--flip-y, 0px)); will-change: transform; }
  .footer span { color: #f2f2f0; }
  .footer .cta { background: #0088ff; color: #ffffff; font-size: 13px; font-weight: 700; padding: 14px 26px; border-radius: 999px; transition: background .2s ease, transform .4s ease; }
  a.cta:hover, button.cta:hover { background: #2e97ff; transform: scale(1.05); }

  a.card { display: block; }
  .page-wipe { position: fixed; inset: 0; z-index: 9999; background: #0088ff; transform: translateY(0); pointer-events: none; }
  .page-wipe.animating { transition: transform .45s cubic-bezier(0.65, 0, 0.35, 1); }
  @media (prefers-reduced-motion: reduce) {
    .page-wipe { transition: none !important; transform: translateY(130%) !important; }
  }
  /* Kept in sync with the same rule in css/style.css. */
  @media (max-width: 640px) {
    .page-wipe {
      background:
        linear-gradient(#161616, #161616) top / 100% 5px no-repeat,
        linear-gradient(#161616, #161616) bottom / 100% 5px no-repeat,
        #0088ff;
    }
  }
  /* Kept in sync with the same rules in css/style.css. */
  .c > *:not(.footer):not(.lightbox):not(.nav):not(canvas) { transition: opacity .6s cubic-bezier(0.22, 1, 0.36, 1), transform .6s cubic-bezier(0.22, 1, 0.36, 1); }
  .c > canvas, .blob { transition: opacity .6s ease; }
  html[data-pw-mode="fade"] .c > *:not(.footer):not(.lightbox):not(.nav):not(canvas):not(.masonry):not(:has(.reveal-piece)) { opacity: 0; transform: translateY(32px); }
  /* .masonry is carved out of the two rules above/below entirely (rather
     than just adding a higher-specificity override) so it doesn't have to
     out-specificity a selector with four :not() clauses -- it needs its
     thumbnails loaded too (see the .imgs-ready class added by
     js/homepage.js) before it's allowed to actually reveal, otherwise the
     grid fades/slides in with blank image boxes that then visibly pop in
     a moment later once each thumbnail finishes loading. */
  html[data-pw-mode="fade"] .masonry { opacity: 0; transform: translateY(32px); }
  html[data-pw-mode="fade"] .c > canvas { opacity: 0; }
  html[data-pw-mode="fade"] .blob { opacity: 0; }
  html[data-pw-mode="fade"][data-pw-revealed="1"] .c > *:not(.footer):not(.lightbox):not(.nav):not(canvas):not(.masonry):not(:has(.reveal-piece)) { opacity: 1; transform: translateY(0); }
  html[data-pw-mode="fade"][data-pw-revealed="1"] .masonry.imgs-ready { opacity: 1; transform: translateY(0); }
  html[data-pw-mode="fade"][data-pw-revealed="1"] .c > canvas { opacity: 1; }
  html[data-pw-mode="fade"][data-pw-revealed="1"] .blob { opacity: 0.22; }
  /* .hero's eyebrow/h1/h1/p each build in independently (with their own
     nested stagger) rather than fusing into one animated block -- same
     .reveal-piece mechanism as css/style.css; kept in sync there. */
  .reveal-piece { transition: opacity .6s cubic-bezier(0.22, 1, 0.36, 1), transform .6s cubic-bezier(0.22, 1, 0.36, 1); }
  html[data-pw-mode="fade"] .reveal-piece { opacity: 0; transform: translateY(32px); }
  html[data-pw-mode="fade"][data-pw-revealed="1"] .reveal-piece { opacity: 1; transform: translateY(0); }
  @media (prefers-reduced-motion: reduce) {
    html[data-pw-mode="fade"] .c > *:not(.footer):not(.lightbox):not(.nav):not(canvas) { transition: none !important; opacity: 1 !important; transform: none !important; }
    html[data-pw-mode="fade"] .c > canvas { transition: none !important; opacity: 1 !important; }
    html[data-pw-mode="fade"] .blob { transition: none !important; opacity: 0.22 !important; }
    html[data-pw-mode="fade"] .reveal-piece { transition: none !important; opacity: 1 !important; transform: none !important; }
  }

  /* ===== Responsive ===== */
  /* Unused .masonry-col groups (when repack() in js/homepage.js packs
     cards into fewer than 3 columns for tablet/mobile) are hidden via a
     direct style.display toggle in repack() itself, not CSS :empty --
     a column's original HTML still has whitespace text nodes between
     where its cards used to be, so :empty never actually matches it. */

  @media (max-width: 900px) {
    .nav { padding: 26px 32px; }
    .hero { padding: 28px 32px 44px; }
    .hero h1 { font-size: 44px; }
    .filters { padding: 0 32px 28px; }
    .masonry { padding: 0 32px; gap: 18px; }
    .footer { padding: 40px 32px; }
  }

  @media (max-width: 640px) {
    body, .c { overflow-x: hidden; }
    .nav { padding: 20px; flex-wrap: wrap; row-gap: 10px; }
    .nav .links { gap: 16px; font-size: 12px; }
    .hero { padding: 24px 20px 36px; max-width: 100%; }
    .hero h1 { font-size: 32px; }
    .hero p { font-size: 15.5px; max-width: 100%; }
    .filters { padding: 0 20px 24px; gap: 8px; }
    .filter-btn { padding: 8px 14px; font-size: 12px; }
    .masonry { flex-direction: column; padding: 0 20px; gap: 16px; }
    .masonry-col { flex: none; width: 100%; gap: 16px; }
    .card .scrim { padding: 34px 16px 16px; }
    .card .scrim h3 { font-size: 19px; }
    .marquee { margin: 40px 20px 0; }
    .footer { padding: 32px 20px; flex-direction: column; align-items: flex-start; gap: 18px; }
  }
