/* Category project pages (Branding / Social / Web Design / Other) — light
   theme, scrollable grid of project thumbnails, separate from the dark
   keychain-cluster home page which keeps its own page.css. */
/* Alturas de ecrã: `svh` em vez de `vh` em tudo o que posiciona ou
   espaça conteúdo que rola.
   No telemóvel a barra de endereço esconde-se e reaparece durante o
   scroll, e `vh` acompanha-a — medido, uma variação de 67px na altura
   do ecrã deslocava todos os projectos 30 a 37px e mexia na posição do
   scroll, a meio do gesto, com o snap obrigatório a re-resolver por
   cima. `svh` é medido com a barra visível e não muda depois disso, por
   isso o layout deixa de depender de uma coisa que se mexe sozinha.
   O `vh` fica declarado antes de cada um como recurso para browsers que
   não conheçam a unidade nova. */

/* Page-to-page fade: see transition.css/transition.js (shared across all
   5 pages) — a plain JS-driven opacity crossfade instead of the
   Chromium-only View Transitions API, so it actually works regardless
   of which browser this is viewed in. */

@font-face {
  font-family: "Ouma Devanagari";
  src: url("assets/font1/fonnts.com-Ouma_Devanagari_Regular.otf") format("opentype");
  font-weight: 400;
  font-style: normal;
  font-display: swap;
}

@font-face {
  font-family: "Ouma Devanagari";
  src: url("assets/font1/fonnts.com-Ouma_Devanagari_Bold.otf") format("opentype");
  font-weight: 700;
  font-style: normal;
  font-display: swap;
}

@font-face {
  font-family: "Alte Haas Grotesk";
  src: url("assets/font2/AlteHaasGroteskRegular.ttf") format("truetype");
  font-weight: 400;
  font-style: normal;
  font-display: swap;
}

* {
  box-sizing: border-box;
}

/* Present for assistive technology and for the document outline, absent
   from the design. Every category page's name is carried by the scrolling
   banner, which is decorative and marked aria-hidden — so to a screen
   reader these pages had no heading at all, and no way to say where you
   were. Clipped rather than display:none, which would remove it from the
   accessibility tree along with everything else. */
.visually-hidden {
  position: absolute;
  width: 1px;
  height: 1px;
  margin: -1px;
  padding: 0;
  overflow: hidden;
  clip: rect(0 0 0 0);
  clip-path: inset(50%);
  white-space: nowrap;
  border: 0;
}

html, body {
  margin: 0;
  padding: 0;
  width: 100vw;
  min-height: 100vh;
  min-height: 100svh;
}

/* Each gallery item is its own section, so scrolling settles onto one
   rather than drifting to rest between two. `mandatory` rather than
   `proximity` because proximity only pulls you in if you happened to
   stop close enough — end a gesture a bit short and a project just sits
   off-centre, which is precisely the "not properly centred" problem.
   What actually made snapping feel aggressive was `scroll-snap-stop:
   always` on every item: that halts momentum dead at each one, so
   crossing several projects took several separate gestures. That's now
   reserved for tall pieces alone (see .gallery-item--tall below), where
   being carried past the top of the artwork is a real loss. Short
   projects keep free-flowing momentum and simply always land centred.
   Lives on html since that's the actual scrolling box here (body has no
   overflow of its own). */
html {
  scroll-snap-type: y mandatory;
  /* Makes the browser *animate* its snap correction instead of applying
     it as an immediate jump. It matters most on the tall pieces, where
     the step between the top framing and the lower one is several
     hundred pixels — long enough that landing it instantly gives no
     chance to actually follow the artwork past. Programmatic scrolls in
     category-page.js all pass their own explicit `behavior`, so none of
     them are affected by this. */
  scroll-behavior: smooth;
}

/* And switched off entirely on a phone, where it costs more than it
   buys.

   A snap point aligned to `center` is a scroll position derived from
   the viewport's height. On a desktop that height is a constant. On a
   phone it is not: the address bar hides as you scroll down and returns
   as you scroll up, and every toggle moves the centre, so mandatory
   snap re-resolves and yanks the page to a new position in the middle
   of the gesture that caused it. Measured here, at 375x812 with the bar
   toggling: the scroll position jumped 30px and every project moved
   37px. `proximity` measured identically — the alignment is the
   problem, not the strictness. With snapping off both numbers fall to
   zero, and what is left is ordinary momentum scrolling, which is what
   a thumb expects anyway.

   Nothing is lost that a phone had: the focus effect still reads
   whichever project is nearest the middle and brings it forward, so
   scrolling still settles the reader onto one project at a time. It
   simply stops being forced. */
@media (hover: none) and (pointer: coarse) {
  html {
    scroll-snap-type: none;
  }
}

/* While a project's info panel is open (see wireToggle in
   category-page.js), snapping is switched off entirely — otherwise the
   moment the user scrolls the text into a comfortable spot, mandatory
   snap would just pull the page straight back to the image's own snap
   point, fighting the exact thing they're trying to do. */
html.gallery-reading-mode {
  scroll-snap-type: none;
}

/* While the next category is being dragged in (see page-transition.js),
   the page underneath must hold absolutely still: mandatory snap would
   otherwise keep resolving against a gallery the reader has already
   scrolled past the end of, and smooth scrolling would animate against
   a gesture that is already driving the layer directly. */
html.pt-transitioning {
  scroll-snap-type: none;
  scroll-behavior: auto;
}

@media (prefers-reduced-motion: reduce) {
  html {
    scroll-snap-type: none;
    scroll-behavior: auto;
  }
}

/* Stated on the root as well as on body. body's background does
   propagate to the canvas, but only once there is a body to propagate
   from — and a document's very first paint can land before that, which
   is a frame of the browser's own white. Naming it here means the page
   is never any colour but its own, from the first paint onwards. */
html {
  background: #ececee;

  /* The banner's height, named here rather than written into each rule
     that needs it, because three separate things have to agree on it:
     the bar itself, the tile scrolling inside it, and the strip the
     category transition docks against the bottom edge — which stands
     exactly where the real banner will be, and would give the whole
     movement away by a few pixels if the two ever drifted apart.

     The tile is kept well inside the bar so the titles have air above
     and below rather than filling it edge to edge; `background-position:
     center` is what holds them centred, at any bar height. */
  --banner-height: 2.4rem;
  --banner-tile-height: 1.65rem;
}

body {
  background: #ececee;
  color: #1a1a1a;
  font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
  overflow-x: hidden;
}

/* Horizontal-scrolling category banner: a single wide track filled with
   the category's repeatable icon+text SVG as a tiled background, shifted
   left by exactly one tile's width per animation cycle so the loop point
   is seamless — the tile itself supplies the per-page image via inline
   style. Animating transform (not background-position) keeps this on the
   compositor instead of repainting every frame. */
.scroll-banner {
  position: fixed;
  top: 0;
  left: 0;
  z-index: 15;
  width: 100%;
  height: var(--banner-height);
  overflow: hidden;
  background-color: #1a1a1a;
}

.scroll-banner-track {
  height: 100%;
  width: 200vw;
  background-repeat: repeat-x;
  /* `center` on the cross axis is what keeps the titles centred in the
     bar whatever height either of them is given. */
  background-position: left center;
  background-size: auto var(--banner-tile-height);
  animation: scroll-banner-x 3.2s linear infinite;
  will-change: transform;
}

@keyframes scroll-banner-x {
  from { transform: translateX(0); }
  /* Exactly one tile's width, so the loop point is invisible. Derived
     from the tile's own height rather than restated as a number:
     --tile-vb-w is the tile's cropped SVG viewBox width (set per page,
     inline, alongside background-image) and the viewBox height is
     always 137, so height × width ÷ 137 is the width it renders at.
     Written as a constant, this silently stopped matching the moment
     the tile was resized, and a loop that shifts by the wrong distance
     stutters once per cycle. */
  to { transform: translateX(calc(-1 * var(--banner-tile-height) * var(--tile-vb-w) / 137)); }
}

@media (prefers-reduced-motion: reduce) {
  .scroll-banner-track {
    animation: none;
  }
}

.back-button {
  position: fixed;
  top: 6rem; /* stays put near the actual top of the page; .project-grid's own
  top margin is topped up dynamically per page in category-page.js so the
  first item can reach true scroll-snap center, which can push it well
  past this fixed corner button */
  left: 1.77rem; /* 1.5rem + 10% of the button's own ~43px width */
  z-index: 10;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  padding: 0.4rem 0.65rem;
  border: 1.2px solid #1a1a1a;
  border-radius: 999px;
  background: transparent;
  text-decoration: none;
  color: #1a1a1a;
}

.back-icon {
  font-family: "Ouma Devanagari", sans-serif;
  font-weight: 400;
  font-size: 0.75rem;
  line-height: 1;
  transition: font-weight 0.25s ease;
}

/* same reversible bold-on-interaction treatment as the CTA button below */
.back-button:hover .back-icon,
.back-button:focus-visible .back-icon,
.back-button:active .back-icon {
  font-weight: 700;
}

.project-grid {
  max-width: 1000px;
  /* Top margin clears the banner and back button; bottom keeps the
     gallery off the end of the page. These are the no-JS/reduced-motion
     fallbacks — with scroll-snap active, category-page.js replaces both
     (see applyEdgeSpacing) with the back button's own bottom edge on
     top, and whatever the last item needs on the bottom. 7.75rem matches
     that measured top edge closely enough that the static rendering
     doesn't visibly differ. */
  margin: 7.75rem auto 0;
  padding: 0 2rem 14rem;
}

.project-grid .grid-inner {
  display: grid;
  grid-template-columns: repeat(4, 1fr);
  gap: 2px;
  background: #c9cacc;
  box-shadow: 0 10px 30px rgba(0, 0, 0, 0.15);
}

.project-grid .tile {
  aspect-ratio: 0.85;
  background: #4a4a4a;
}

/* Stacked gallery variant: real project thumbnails, one per row at full
   width (free height each), in place of the fixed-aspect placeholder grid
   above (still used by the category pages whose gallery hasn't been
   uploaded yet). */
.grid-inner.grid-inner--stack {
  display: flex;
  flex-direction: column;
  align-items: center;
  background: transparent;
  box-shadow: none;
}

.gallery-item {
  width: 100%;
  /* The actual "how long does this project stay on screen" control —
     genuine scroll distance between one project and the next, rather
     than any timing trick. Scaled to the viewport instead of a flat
     13rem: on a short window that fixed gap was most of a screen of
     dead space between projects, and on a tall one it barely
     registered. The clamp keeps it sane at both extremes. */
  margin-bottom: clamp(7rem, 16vh, 11rem);
  margin-bottom: clamp(7rem, 16svh, 11rem);
  text-align: center;
  /* Centers the *whole* item — image and caption/button together, as
     one section — not just the image with the caption excluded (see
     category-page.js, which used to compensate this with a negative
     scroll-margin-bottom so only the image landed centered). The
     button was then a further scroll away from wherever the gallery
     actually stopped; now it's already on screen the moment its
     project settles into place. This default is what short/normal
     items use as-is; category-page.js overrides it per item for the
     first item (fixed top edge, for cross-page consistency — see
     applySnapAlignment) and for tall portrait pieces (see
     .gallery-item--tall below, a second stop rather than one section). */
  scroll-snap-align: center;
}

.gallery-item:last-child {
  margin-bottom: 0;
}

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

/* Portrait pieces tall enough that the whole image+caption section
   can't reasonably share one screen (Aurora, Crafthouse, Sandsavers,
   Shift Gallery, Zenzoo…) render at their original scale — no cap —
   and instead get a second scroll-snap stop of their own. See
   applySnapAlignment in category-page.js: the item's own top is the
   first stop (as for every item), and .gallery-item--tall figcaptions
   additionally snap-align "end", landing the lower part of the artwork
   plus the caption together as the second, so the piece is seen in two
   deliberate stages rather than needing to fit whole or scrolling
   through it freely. Short/normal work never gets this class and is
   unaffected. */
/* Stage one: forced stop, so a fast gesture can never carry the reader
   straight past the top of a tall piece and dump them at its bottom
   edge with the artwork already half gone. This is the only place
   scroll-snap-stop is used — short projects keep free momentum. */
.gallery-item--tall {
  scroll-snap-stop: always;
}

/* Stage two: the lower part of the artwork. scroll-margin-bottom is
   what keeps this from resting with the image jammed flush against the
   bottom of the screen — it pushes the snap area past the real edge, so
   aligning it to the viewport bottom leaves that much visible gap under
   the image instead. Without it the final project's stage two also
   lands beyond the page's own maximum scroll and can't be reached at
   all, which is what made the end of the page snap upward and clip it.

   Which element carries it depends on which control is on screen: the
   caption below the artwork when that's what's visible (mobile), and
   the media box itself once the caption is hidden in favour of the
   floating button (desktop — see the breakpoint rules further down).
   Only ever one of the two, or a tall piece would end up with two
   competing stops a caption's height apart. */
.gallery-item--tall figcaption {
  scroll-snap-align: end;
  scroll-snap-stop: always;
  scroll-margin-bottom: 6rem;
}

@media (min-width: 701px) {
  body.project-reveal .gallery-item--tall .gallery-media {
    scroll-snap-align: end;
    scroll-snap-stop: always;
    scroll-margin-bottom: 6rem;
  }
}

/* Scroll-driven focus effect (see wireScrollFocus in category-page.js):
   the item nearest the viewport's center reads as "in focus" (sharp,
   slightly larger, full opacity) while others recede — like prints
   hanging in space, catching the light as they pass center. Targets
   only the direct-child media unit (not the individual stacked
   screenshots inside a Web Design .gallery-frame, which would otherwise
   double up with the wrapper's own scaling), and JS drives everything
   through these custom properties so the CSS side stays a pure function
   of state — no transitions here, the JS loop already interpolates
   toward each target every frame for the smoothing/"weight". Defaults
   below (scale 1, opacity 1, no blur) are what renders if JS never
   runs (prefers-reduced-motion, or disabled) — a plain static gallery,
   not a broken one. */
.gallery-item > img,
.gallery-item > .gallery-video,
.gallery-item > .gallery-frame-wrap,
.gallery-item > .gallery-media > img,
.gallery-item > .gallery-media > .gallery-video,
.gallery-item > .gallery-media > .gallery-frame-wrap {
  --gf-scale: 1;
  --gf-y: 0px;
  --gf-slide-x: 0px;
  --gf-opacity: 1;
  --gf-brightness: 1;
  --gf-contrast: 1;
  --gf-blur: 0px;
  --gf-shadow-blur: 16px;
  --gf-shadow-alpha: 0.1;

  /* --gf-slide-x is only ever non-zero in branding's reveal mode (see
     REVEAL_MODE in category-page.js) — folding it into the same
     JS-driven transform, instead of a separate CSS-transitioned rule,
     avoids the two fighting over the same `transform` property every
     frame. */
  transform: translateY(var(--gf-y)) translateX(var(--gf-slide-x)) scale(var(--gf-scale));
  opacity: var(--gf-opacity);
  filter: brightness(var(--gf-brightness)) contrast(var(--gf-contrast)) blur(var(--gf-blur));
  box-shadow: 0 18px var(--gf-shadow-blur) rgba(0, 0, 0, var(--gf-shadow-alpha));
  will-change: transform, opacity, filter;
}

@media (prefers-reduced-motion: reduce) {
  .gallery-item > img,
  .gallery-item > .gallery-video,
  .gallery-item > .gallery-frame-wrap,
  .gallery-item > .gallery-media > img,
  .gallery-item > .gallery-media > .gallery-video,
  .gallery-item > .gallery-media > .gallery-frame-wrap {
    transform: none;
    opacity: 1;
    filter: none;
    box-shadow: none;
  }
}

/* Web Design variant: the image itself can be much taller than the box
   (a full-length page screenshot), so it scrolls inside a fixed-height
   frame instead of stretching the row — the frame, not .gallery-item,
   owns the fixed size, so the caption below stays outside the scroll
   area and unaffected. Projects with several page screenshots stack
   them all inside the same frame and toggle which one is visible via
   .is-active, so only one is ever in flow (and scrollable) at a time.
   The frame is always full width (same as a plain .gallery-item image
   on the branding/social pages); the prev/next arrows sit outside it,
   overhanging into the page's side margin via negative offsets, so
   they never take width away from the frame or sit over the image. */
.gallery-frame-wrap {
  position: relative;
  width: 100%;
}

.gallery-frame {
  width: 100%;
  height: var(--frame-h, 34rem);
  overflow-y: auto;
  overflow-x: hidden;
  border: 1px solid rgba(26, 26, 26, 0.15);
  scrollbar-width: thin;
  scrollbar-color: rgba(26, 26, 26, 0.35) transparent;
}

.gallery-frame::-webkit-scrollbar {
  width: 6px;
}

.gallery-frame::-webkit-scrollbar-track {
  background: transparent;
}

.gallery-frame::-webkit-scrollbar-thumb {
  background-color: rgba(26, 26, 26, 0.35);
  border-radius: 3px;
}

.gallery-frame img,
.gallery-frame .gallery-video {
  display: none;
  width: 100%;
  height: auto;
}

.gallery-frame img.is-active,
.gallery-frame .gallery-video.is-active {
  display: block;
}

/* same pill style, weight and sizing as .back-button, just positioned
   outside the frame instead of fixed to the viewport */
.gallery-frame-arrow {
  position: absolute;
  top: 50%;
  transform: translateY(-50%);
  display: inline-flex;
  align-items: center;
  justify-content: center;
  padding: 0.4rem 0.65rem;
  border: 1.2px solid #1a1a1a;
  border-radius: 999px;
  background: transparent;
  color: #1a1a1a;
  font-family: "Ouma Devanagari", sans-serif;
  font-weight: 400;
  font-size: 0.75rem;
  line-height: 1;
  cursor: pointer;
  transition: font-weight 0.25s ease;
}

.gallery-frame-arrow:hover,
.gallery-frame-arrow:focus-visible {
  font-weight: 700;
}

.gallery-frame-arrow--prev {
  left: -3.25rem;
}

.gallery-frame-arrow--next {
  right: -3.25rem;
}

/* Once the frame is as wide as the screen there is no "outside" left to
   sit in, and these were ending up almost entirely past the edge — five
   visible pixels each, which on a phone means the carousels simply
   cannot be stepped through. Brought inside the frame at that point,
   over the artwork's own margin, which is the only place left for them.
   They keep the same pill, size and vertical placement; a translucent
   ground is added purely so they stay legible against whatever they now
   sit on top of. */
@media (max-width: 900px) {
  .gallery-frame-arrow {
    background: rgba(253, 253, 252, 0.82);
    backdrop-filter: blur(8px);
    -webkit-backdrop-filter: blur(8px);
  }

  .gallery-frame-arrow--prev {
    left: 0.5rem;
  }

  .gallery-frame-arrow--next {
    right: 0.5rem;
  }
}

/* Bare "+" pill, styled identically to .back-button — same border weight,
   padding, radius and glyph font, just showing "+" instead of "×". Click
   opens .gallery-subtitle below it (see category-page.js); the icon
   itself rotates 45deg on open, turning the "+" into a "×" as the only
   visual cue, matching the back-button's own glyph. */
.gallery-caption {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  margin-top: 2.75rem;
  padding: 0.4rem 0.65rem;
  border: 1.2px solid #1a1a1a;
  border-radius: 999px;
  background: transparent;
  cursor: pointer;
  transition: border-color 0.6s cubic-bezier(0.22, 1, 0.36, 1),
              background-color 0.6s cubic-bezier(0.22, 1, 0.36, 1),
              opacity 0.4s ease, transform 0.4s cubic-bezier(0.22, 1, 0.36, 1);
}

.gallery-icon {
  font-family: "Ouma Devanagari", sans-serif;
  font-weight: 400;
  font-size: 0.75rem;
  line-height: 1;
  color: #1a1a1a;
  transition: font-weight 0.25s ease, transform 0.3s ease;
}

/* hovering/focusing the thumbnail bolds the "+" — reversible, and only
   while still closed (not yet open). */
.gallery-item:not(.is-open):hover .gallery-icon,
.gallery-item:not(.is-open):focus-within .gallery-icon,
.gallery-caption:active .gallery-icon {
  font-weight: 700;
}

/* the small line that appears once the caption unwraps into a bare title */
.gallery-subtitle {
  font-family: "Alte Haas Grotesk", sans-serif;
  font-size: 0.85rem;
  color: rgba(26, 26, 26, 0.6);
  /* The descriptions are written in paragraphs, separated by newline
     character references in the markup. pre-line keeps those breaks
     while still collapsing ordinary runs of whitespace, so the text can
     be paragraphed without each project needing its own elements. */
  white-space: pre-line;
  max-height: 0;
  margin-top: 0;
  opacity: 0;
  transform: translateY(-6px);
  overflow: hidden;
  transition: opacity 0.5s ease 0.15s, transform 0.5s ease 0.15s,
              max-height 0.5s cubic-bezier(0.22, 1, 0.36, 1), margin-top 0.5s ease;
}

/* click: the pill itself stays put — only the subtitle below it opens.
   The "+" simply rotates into a "×" as the only cue the pill is now
   open. Toggles back on a second click (or automatically once the user
   scrolls — see wireToggle in category-page.js). */
.gallery-caption.is-open .gallery-icon {
  font-weight: 700;
  transform: rotate(45deg);
}

.gallery-item.is-open .gallery-subtitle {
  opacity: 1;
  transform: translateY(0);
  /* Generous enough for a real paragraph of project description, not
     just the current one-line placeholder — and if the text is ever
     genuinely longer than that, it scrolls within its own box instead
     of silently clipping or forcing the whole gallery to fight the
     reader for space. */
  max-height: min(45vh, 26rem);
  max-height: min(45svh, 26rem);
  overflow-y: auto;
  margin-top: 0.5rem;
}

/* The "reveal" interaction (see body.project-reveal on every gallery
   page, and revealActive() in category-page.js): a single floating
   "+"/"×" button and a single floating text panel (see
   .gallery-caption--floating / .gallery-textpanel--floating below) sit
   fixed to the viewport and re-target themselves to whichever project
   currently has focus. The panel is one shared, fixed-position element
   rather than one per project because a tall piece's own box can run
   well over 1000px — a panel anchored *inside* it could end up centred
   hundreds of pixels off screen, or get left stranded after scrolling
   past the project it was opened for. Opening it slides the active
   project's image left (via --gf-slide-x, driven by wireScrollFocus's
   own tick loop so it never fights the JS-driven scale/blur transform
   for control of `transform`).

   This is a desktop treatment: it wants room beside the artwork for the
   panel, which a phone simply doesn't have. Below the breakpoint every
   page falls back to the pill sitting under its own project, opening
   the description inline — see the two breakpoint blocks at the end of
   this section, which are what actually swap between the two. Both sets
   of controls are always in the markup; only one is ever displayed. */

/* Desktop: the floating button speaks for the project, so the per-item
   pill and its inline text step aside entirely. */
@media (min-width: 701px) {
  body.project-reveal .gallery-item > figcaption {
    display: none;
  }
}

/* Frosted-glass card — legible over busy artwork without needing a
   flat, opaque block, and reads as a distinct, considered surface
   rather than a plain gray box. Grows outward from the button's own
   anchor point (transform-origin: right center, same fixed `right` as
   the button — see updateFloatingHorizontal in category-page.js) so
   opening it feels like the button expanding into the card rather
   than two unrelated elements independently fading in. */
.gallery-textpanel--floating {
  position: fixed;
  /* Half the banner's own 4rem height below centre — the same optical
     correction applied to where short projects come to rest (see
     applySnapAlignment in category-page.js). Without it this would sit
     centred on the raw viewport while the artwork sits centred on the
     part of it you can actually see, and the two would disagree. */
  top: calc(50% + 2rem);
  right: 2.5rem;
  width: min(60%, 26rem);
  max-height: 70vh;
  max-height: 70svh;
  display: flex;
  flex-direction: column;
  align-items: flex-start;
  gap: 0.6rem;
  /* Deeper on the right than the left, to keep the text clear of the
     close button that docks into that edge when the card opens. Measured
     rather than guessed: the button is 29px wide and sits 14px in, so
     anything short of ~63px leaves the last words of a line running
     underneath it — they were ending 2px past where it begins. */
  padding: 2.25rem 4.25rem 2.25rem 2.5rem;
  text-align: left;
  /* Deliberately sheer — the artwork stays readable through the card.
     The heavier blur/saturation is what buys that transparency back in
     legibility: it softens whatever's behind into a wash of colour
     rather than competing detail. */
  background: rgba(253, 253, 252, 0.6);
  backdrop-filter: blur(42px) saturate(200%);
  -webkit-backdrop-filter: blur(42px) saturate(200%);
  border: 1px solid rgba(255, 255, 255, 0.5);
  border-radius: 1.4rem;
  box-shadow: 0 2px 10px rgba(0, 0, 0, 0.04), 0 30px 60px -12px rgba(0, 0, 0, 0.18);
  opacity: 0;
  transform-origin: right center;
  transform: translateY(-50%) scale(0.7);
  pointer-events: none;
  z-index: 11;
  transition: opacity 0.5s cubic-bezier(0.19, 1, 0.22, 1),
              transform 0.55s cubic-bezier(0.19, 1, 0.22, 1);
}

.gallery-textpanel--floating.is-open {
  opacity: 1;
  transform: translateY(-50%) scale(1);
  pointer-events: auto;
}

.gallery-textpanel-title {
  font-family: "Ouma Devanagari", sans-serif;
  font-weight: 400;
  font-size: 0.95rem;
  letter-spacing: 0.06em;
  text-transform: uppercase;
  color: #1a1a1a;
}

/* The panel's own opacity/transform above is what shows and hides the
   text now, so the subtitle itself skips its usual closed-by-default
   animation (see .gallery-subtitle / .gallery-item.is-open
   .gallery-subtitle) and just renders at rest inside the panel. */
.gallery-textpanel--floating .gallery-subtitle {
  max-width: none;
  max-height: min(55vh, 28rem);
  max-height: min(55svh, 28rem);
  margin-top: 0;
  opacity: 1;
  transform: none;
  overflow-y: auto;
  /* A little darker than the default muted grey — the sheerer card
     above lets more of the artwork through, so the text needs the
     extra weight to hold up against it. */
  color: rgba(26, 26, 26, 0.78);
}

/* Single floating button (see wireScrollFocus in category-page.js) —
   fixed to the viewport, sharing the exact same `right` offset as the
   panel (both computed off the current project's own right edge, not
   a flat viewport margin — see updateFloatingHorizontal), so the two
   sit concentric on each other: the button rests right at the panel's
   own edge rather than floating apart from it. Fades out and back in
   as focus moves to a different project (see setActiveEntry) — each
   project reads as having its own button, not one that just silently
   relabels. Once open, it borrows the panel's own glass surface
   instead of keeping its plain pill background, so it reads as the
   panel's built-in close control rather than a separate circle
   sitting on top of it. */
.gallery-caption--floating {
  position: fixed;
  top: calc(50% + 2rem); /* matches the panel's banner offset above */
  right: 2.5rem;
  z-index: 13;
  margin-top: 0;
  opacity: 0;
  /* Resting hidden state is scaled down and nudged outward, so arriving
     reads as easing into place rather than a bare opacity flip — the
     part that made the swap between projects feel like a jump. */
  transform: translateY(-50%) translateX(8px) scale(0.78);
  pointer-events: none;
  box-shadow: 0 2px 10px rgba(0, 0, 0, 0.03);
  /* This rule is the *leaving* state, so its timing is the fade-out:
     shorter and gently accelerating, versus the long expo settle on
     .is-visible below. setActiveEntry in category-page.js waits out
     this exit (REVEAL_SWAP_MS) before bringing the next project's
     button back, so the two never overlap mid-fade.
     `right` glides the button between its resting spot outside the
     image edge (closed) and its docked spot inside the panel (open) —
     see updateFloatingHorizontal in category-page.js — timed to match
     the panel's own grow transition so the two arrive together. */
  transition: opacity 0.34s cubic-bezier(0.4, 0, 0.7, 1),
              transform 0.34s cubic-bezier(0.4, 0, 0.7, 1),
              right 0.65s cubic-bezier(0.19, 1, 0.22, 1),
              box-shadow 0.45s ease,
              background-color 0.5s ease, border-color 0.5s ease;
}

.gallery-caption--floating.is-visible {
  opacity: 1;
  transform: translateY(-50%) translateX(0) scale(1);
  pointer-events: auto;
  /* Arriving is slower and expo-eased — it decelerates into place
     instead of stopping dead. */
  transition: opacity 0.68s cubic-bezier(0.16, 1, 0.3, 1),
              transform 0.68s cubic-bezier(0.16, 1, 0.3, 1),
              right 0.65s cubic-bezier(0.19, 1, 0.22, 1),
              box-shadow 0.45s ease,
              background-color 0.5s ease, border-color 0.5s ease;
}

.gallery-caption--floating.is-visible:hover,
.gallery-caption--floating.is-visible:focus-visible {
  transform: translateY(-50%) translateX(0) scale(1.1);
  box-shadow: 0 6px 20px rgba(0, 0, 0, 0.11);
}

.gallery-caption--floating.is-visible:active {
  transform: translateY(-50%) translateX(0) scale(0.96);
}

/* Docked inside the panel: takes on the same sheer glass surface so it
   reads as the card's own close control rather than a separate pill
   sitting on top of it — a touch lighter than the panel itself so it
   still stands out against it. */
.gallery-caption--floating.is-open {
  background: rgba(255, 255, 255, 0.5);
  backdrop-filter: blur(20px) saturate(180%);
  -webkit-backdrop-filter: blur(20px) saturate(180%);
  border-color: rgba(26, 26, 26, 0.14);
}

/* A longer, expo-eased glyph turn than the per-item pills on the other
   pages — scoped to the floating button so those stay untouched. */
.gallery-caption--floating .gallery-icon {
  transition: font-weight 0.25s ease,
              transform 0.6s cubic-bezier(0.19, 1, 0.22, 1);
}

/* Mobile: no room beside the artwork for a panel, so the floating pair
   is taken out of play entirely and every page falls back to the pill
   under its own project (which is in the markup on all of them, just
   hidden on desktop above). display:none rather than opacity so it also
   stops being focusable and stops laying out. */
@media (max-width: 700px) {
  .gallery-caption--floating,
  .gallery-textpanel--floating {
    display: none;
  }

  /* Once the work is phone-width the desktop gap leaves projects the
     better part of a screen apart, which reads as scrolling through
     empty page rather than through a portfolio. The floor keeps small
     phones from crowding, the ceiling keeps the tallest ones from
     drifting back toward the problem; in between it lands around 50px
     on a typical device. */
  body.project-reveal .gallery-item {
    margin-bottom: clamp(48px, 6vh, 55px);
    margin-bottom: clamp(48px, 6svh, 55px);
  }

  /* Replaced here by a sideways swipe — see wireSwipeBack in
     category-page.js, which keys off this very rule (it only arms the
     gesture while the button is actually hidden), so the two can never
     disagree about which is in play. */
  body.project-reveal .back-button {
    display: none;
  }
}

/* ---------------------------------------------------------------------
   Scroll-linked navigation between categories (see page-transition.js).

   Two layers: .pt-shell holds whatever the current page draws, and
   .pt-layer holds the page being dragged in. The shell is deliberately
   styleless at rest — no transform, no filter, nothing — because a
   transformed ancestor becomes the containing block for position:fixed
   descendants, which would move the banner, the back button and the
   floating panel out from under their own CSS. JS sets those properties
   only while a transition is actually running, and removes them
   outright afterwards.
   --------------------------------------------------------------------- */

/* Darkens the outgoing page. A separate composited layer rather than a
   filter on the page itself — see render() in page-transition.js. */
.pt-scrim {
  position: fixed;
  inset: 0;
  z-index: 55;
  display: none;
  background: #000;
  opacity: 0;
  pointer-events: none;
}

.pt-scrim.is-active {
  display: block;
}

.pt-layer {
  position: fixed;
  inset: 0;
  z-index: 60;
  overflow: hidden;
  display: none;
  background: #ececee;
  /* transform-driven, so it never triggers layout while being dragged */
  will-change: transform;
  transform: translate3d(0, 100%, 0);
}

/* Promoted here, when the layer first appears, and not later when it
   starts to dissolve. Naming a property in will-change is what hands the
   element its own compositor layer, and doing that at the same instant a
   transition on that property begins means the promotion and the first
   animated frame land together — which is a re-raster of a full-screen
   surface exactly where it will be noticed. Opacity is named well before
   anything animates it. */
.pt-layer.is-active {
  display: block;
  will-change: transform, opacity;
}

/* The one moment in this whole movement where a fade describes what is
   actually happening. After a swap the page underneath genuinely is the
   next category — live, laid out, already decoded — so the covering
   layer doesn't have to be cut away to reveal it. It dissolves off it.
   Everywhere else a fade would only be hiding a gap. */
.pt-layer.is-dissolving {
  opacity: 0;
  /* It is already only scenery by this point, and the live page is
     underneath it — nothing here should be catching a click. */
  pointer-events: none;
  /* Composite the fade rather than repainting a full-viewport surface
     against a gallery on every frame of it. This is the one property
     changing, and saying so is what keeps the curve smooth. */
  /* Lifting away rather than simply ceasing to be there. A surface that
     only loses opacity reads as a light being turned down; the same
     surface easing back at the same time reads as a veil being drawn off
     the page, which is the thing actually happening. Small enough that
     it is felt rather than seen — any more and it becomes an effect.
     Motion gets the site's motion curve here, where opacity does not:
     they are different quantities and the eye reads them differently. */
  transform: scale(1.035);
  /* Evenly, not on the site's usual motion curve. That curve is right
     for things that travel — fast away, settling long — but applied to
     a crossfade it spends two thirds of the opacity in the first fifth
     of the time: the layer is effectively gone in a tenth of a second
     and the remaining three hundred milliseconds are a tail nobody can
     see. Which is a blink followed by nothing, not a dissolve. The eye
     reads opacity more or less linearly, so this is close to linear,
     eased only at the two ends to keep it from starting or stopping
     abruptly. */
  transition: opacity 560ms cubic-bezier(0.37, 0, 0.63, 1),
              transform 720ms cubic-bezier(0.22, 1, 0.36, 1);
}

/* The name goes before the ground it sits on. Fading them together
   would hold sharp black type over the artwork for the whole dissolve,
   which is the one element with enough contrast to stay legible right
   to the end and so the one that would read as lingering. Gone early,
   what is left to dissolve is a plain field over a finished page. */
.pt-layer.is-dissolving .pt-strip-label {
  opacity: 0;
  transition: opacity 320ms cubic-bezier(0.37, 0, 0.63, 1);
}

@media (prefers-reduced-motion: reduce) {
  .pt-layer.is-dissolving,
  .pt-layer.is-dissolving .pt-strip-label {
    transition: none;
  }
}

/* The leading edge carries a soft shadow so the incoming page reads as
   sitting above the one it's covering rather than butted against it —
   the single cue that most sells one surface passing over another. It
   fades up with the drag so it isn't a hard line at first contact. */
/* No leading-edge shadow, by design. The incoming surface is the same
   colour as the one it covers, and drawing a line where they meet is
   what makes the movement read as a slab being pushed up the screen
   rather than the page simply becoming the next one. What separates
   them instead is depth: the page underneath eases back and dims a
   little, so the plain ground rising over it has somewhere to rise
   from. The only mark on it is the name of where you are going. */

.pt-layer-inner {
  position: relative;
  width: 100%;
  min-height: 100%;
  /* Contains the gallery's own top margin. Without this that margin
     collapses out through this box and shifts it down the page, which
     puts everything measured from it — not least where the banner comes
     to rest — out by exactly that margin. */
  display: flow-root;
}

/* Arriving at the end of a transition, the work settles the last few
   pixels up into place as it fades in, so the page resolves rather than
   simply switching on. Deliberately on the gallery alone: body carries
   the banner, the back button and the floating panel, all of them
   position:fixed, and a transform anywhere above them would make that
   element their containing block — moving them for the length of the
   animation and snapping them back at the end.

   The travel is longer than the fade on purpose. The page is legible
   well before it stops moving, so the tail is felt rather than waited
   on, which is the difference between a settle and a delay. */
/* The gallery used to drift the last few pixels up as it faded in here.
   It read well in isolation and it was the wrong thing on the one path
   that needs to feel quickest: this rule only ever applies where a real
   navigation happened, which is precisely where the reader has already
   waited for a document to be built. Adding four tenths of a second of
   travel on top of that is decorating the slow route. The fade alone
   now, and it lands sooner. */

/* The next page, loaded and standing by to be adopted (see frameFor in
   page-transition.js). Never shown — but sized to the real viewport,
   because the nodes handed over carry the layout they were given here,
   and laid out against a 10px box they would arrive measured for one.
   visibility:hidden rather than display:none: a display:none frame
   lays out nothing at all, and its images never load. */
.pt-source {
  position: fixed;
  top: 0;
  left: 0;
  width: 100%;
  height: 100%;
  border: 0;
  visibility: hidden;
  pointer-events: none;
  z-index: -1;
}

/* The surface that rises during a transition: the destination named on
   a strip, over the background the page it stands for will arrive on —
   so the hand-off at the end is the work appearing, not the ground
   changing colour underneath it. See page-transition.js. */
.pt-page-preview {
  position: absolute;
  inset: 0;
  background: #ececee;
}

/* Home has its own stylesheet, which restyles body wholesale; this
   borrows its warm charcoal so the edge that appears belongs
   unmistakably to that page. */
.pt-preview-home {
  background: #17140f;
}

/* Same height and place as a category's own banner, so what docks
   against the bottom edge sits exactly where the real strip will be —
   and is what the detent is measured from.

   Between categories it is a band of the destination's own ground with
   its name on it, and nothing else: no bar, no marquee. Both pages are
   the same colour, so what rises has no edge of its own to drag up the
   screen — only the name, and the shadow the leading edge casts. That
   is what makes the movement read as continuous rather than as a slab
   being pushed into view. Home keeps a bar, because Home is a dark page
   and the band is announcing that too. */
.pt-strip {
  position: absolute;
  top: 0;
  left: 0;
  right: 0;
  /* The same height as the real banner, from the same source. This is
     what docks against the bottom edge and what the detent is measured
     from, and it lands where that banner will be — a few pixels of
     disagreement and the arrival stops lining up. */
  height: var(--banner-height);
  display: flex;
  align-items: center;
  justify-content: center;
  padding: 0 1.5rem;
  background: transparent;
}

.pt-preview-home .pt-strip {
  background: #17140f;
}

/* Deliberately *not* faded out during the rise. It used to be, on the
   reasoning that its job was done once it reached the top — but that
   left a stretch with the label already gone and the page not yet
   uncovered, which is nothing at all on screen: a flat field, holding.
   Short as it was, that hold was the most conspicuous thing in the
   whole movement, because it was the only moment nothing was happening.
   The label now stays up until the swap and leaves with the layer that
   carries it, so the page underneath is arriving in the same breath. */
.pt-strip-label {
  margin: 0;
  font-family: "Ouma Devanagari", sans-serif;
  font-weight: 400;
  font-size: 1.1rem;
  letter-spacing: 0.3em;
  text-transform: uppercase;
  text-align: center;
  line-height: 1.4;
  color: #1a1a1a;
}

.pt-preview-home .pt-strip-label {
  color: rgba(255, 255, 255, 0.92);
}

/* Says which way the gesture goes, on the two strips that are asking
   for one. Sized in em so it tracks the label rather than needing its
   own breakpoints, and nudged up off the baseline to sit optically
   centred against uppercase letterforms. */
/* Turns with the gesture that is pulling the page up, not on a timer of
   its own — so it is being turned by the reader rather than performing
   at them. It points down while it is still an invitation, and has come
   all the way about by the time the page it was pointing at has
   arrived. inline-block because a plain inline box takes no transform. */
.pt-strip-arrow {
  display: inline-block;
  width: 0.6em;
  height: 0.85em;
  margin-left: 0.5em;
  vertical-align: -0.1em;
  fill: none;
  stroke: currentColor;
  stroke-width: 1.4;
  stroke-linecap: round;
  stroke-linejoin: round;
  transform: rotate(calc(var(--pt-act2, 0) * 180deg));
}

/* Home's strip carries a sentence where the others carry a name, so it
   is set smaller and tracked tighter — enough to stay on one line at
   ordinary widths without losing the strip's spaced-out voice. */
.pt-strip-label--message {
  font-size: clamp(0.58rem, 1.35vw, 0.9rem);
  letter-spacing: 0.16em;
}

/* The one thing on these strips that can be acted on, so it is the one
   thing marked. Underlined rather than coloured: the strip has no
   palette to spend, and an underline reads as a link at any size. */
.pt-strip-link {
  color: inherit;
  text-decoration: underline;
  text-underline-offset: 0.35em;
  text-decoration-thickness: 1px;
  transition: opacity 0.3s ease;
}

.pt-strip-link:hover,
.pt-strip-link:focus-visible {
  opacity: 0.65;
}

.project-cta {
  display: flex;
  justify-content: center;
  margin: 2.5rem 0 5rem;
}

.cta-wrap {
  display: flex;
  flex-direction: column;
  align-items: center;
  text-align: center;
}

.cta-button {
  display: inline-flex;
  align-items: center;
  gap: 0.425rem;
  padding: 0.8rem 1.35rem;
  border: 1.3px solid #1a1a1a;
  border-radius: 999px;
  background: transparent;
  cursor: pointer;
  transition: border-color 0.6s cubic-bezier(0.22, 1, 0.36, 1),
              background-color 0.6s cubic-bezier(0.22, 1, 0.36, 1),
              padding 0.6s cubic-bezier(0.22, 1, 0.36, 1),
              gap 0.4s cubic-bezier(0.22, 1, 0.36, 1);
}

.cta-label {
  font-family: "Ouma Devanagari", sans-serif;
  font-weight: 400;
  font-size: 0.72rem;
  letter-spacing: 0.05em;
  text-transform: uppercase;
  color: #1a1a1a;
  transition: font-weight 0.25s ease,
              font-size 0.6s cubic-bezier(0.22, 1, 0.36, 1),
              letter-spacing 0.6s cubic-bezier(0.22, 1, 0.36, 1);
}

/* scaled ~15% down from a prior 19.8px/12.1px pass */
.cta-icon {
  width: 16.8px;
  height: 16.8px;
  border-radius: 50%;
  border: 1.3px solid currentColor;
  display: flex;
  align-items: center;
  justify-content: center;
  font-family: "Ouma Devanagari", sans-serif;
  font-weight: 400;
  font-size: 10.3px;
  line-height: 1;
  color: #1a1a1a;
  transition: font-weight 0.25s ease, opacity 0.4s ease, transform 0.4s ease;
}

/* bold while hovered or pressed, back to regular the instant you release
   or move away — reversible, and only while still a pill (not yet opened). */
.cta-button:not(.is-open):hover .cta-label,
.cta-button:not(.is-open):focus-visible .cta-label,
.cta-button:not(.is-open):active .cta-label,
.cta-button:not(.is-open):hover .cta-icon,
.cta-button:not(.is-open):focus-visible .cta-icon,
.cta-button:not(.is-open):active .cta-icon {
  font-weight: 700;
}

/* the small line that appears once the button unwraps into a bare title */
.cta-subtitle {
  font-family: "Alte Haas Grotesk", sans-serif;
  font-size: 0.85rem;
  color: rgba(26, 26, 26, 0.6);
  max-height: 0;
  margin-top: 0;
  opacity: 0;
  transform: translateY(-6px);
  overflow: hidden;
  transition: opacity 0.5s ease 0.15s, transform 0.5s ease 0.15s,
              max-height 0.5s cubic-bezier(0.22, 1, 0.36, 1), margin-top 0.5s ease;
}

/* click: box disappears, title grows into a bare heading, subtitle fades
   in below it. Toggles back to the pill on a second click. */
.cta-button.is-open {
  gap: 0;
  border-color: transparent;
  background: transparent;
  padding: 0.6rem 0;
  cursor: pointer;
}

.cta-button.is-open .cta-label {
  font-size: 1.7rem;
  font-weight: 400;
  letter-spacing: 0.02em;
}

/* collapsed out of the layout (not just faded) so the label is the only
   box left and its centering matches the subtitle's below exactly. */
.cta-button.is-open .cta-icon {
  width: 0;
  border-width: 0;
  opacity: 0;
  transform: scale(0.4);
  transition: font-weight 0.25s ease, opacity 0.3s ease, transform 0.3s ease,
              width 0.4s cubic-bezier(0.22, 1, 0.36, 1), border-width 0.4s ease;
}

.cta-wrap.is-open .cta-subtitle {
  opacity: 1;
  transform: translateY(0);
  max-height: min(45vh, 26rem);
  max-height: min(45svh, 26rem);
  overflow-y: auto;
  margin-top: 0.5rem;
}

@media (max-width: 700px) {
  .project-grid .grid-inner {
    grid-template-columns: repeat(2, 1fr);
  }
}
