/* Bullish Media — /works.html, the "creative space" sphere.

   This stylesheet owns nothing but the page shell: the fixed stage the WebGL
   canvas mounts into, the DOM overlay that floats over it, and the plain
   index underneath that becomes the page when the engine cannot run. Every
   motion value the overlay needs (reveal, chip follow, expand) is applied by
   JS in works-overlay.js — what lives here is the resting state each of those
   animations starts from, so the page is correct before a single frame runs.

   Palette and type come from styles.css tokens; the theme tokens the pill
   menu reads come from menu.css. Neither is touched. */

/* The trail canvas sits at z -1, i.e. between html's paint and the body's
   content — so body must not paint over it. html already carries --bg from
   styles.css, which is what the page background actually is. Same trick
   pages.css plays for the static pages, keyed to this page's data-page. */
body[data-page='works'] {
  background: transparent;
}

/* --------------------------------------------------------------- the stage

   A fixed viewport, not a scrolling document: the constellation is spun, not
   scrolled, and nothing on this page is ever below the fold. 100dvh rather
   than 100vh because iOS measures 100vh against the *expanded* toolbar and
   would crop the bottom of the sphere until the visitor scrolled — which they
   cannot do here. */
.works {
  position: fixed;
  inset: 0;
  width: 100%;
  height: 100dvh;
  overflow: hidden;
  background: transparent;
}

.works__stage {
  position: absolute;
  inset: 0;
  /* The browser's own gesture handling would swallow the wheel and the drag
     before the engine ever saw them. On this page the pointer belongs to the
     sphere. */
  touch-action: none;
}

.works__stage canvas {
  display: block;
  width: 100%;
  height: 100%;
}

/* ------------------------------------------------------------- the overlay

   One transparent layer over the whole stage. It must not eat the pointer —
   every event on this page is a raycast against the canvas underneath — so
   the layer is inert and the few genuinely interactive children opt back in
   one at a time. */
.works__overlay {
  position: absolute;
  inset: 0;
  z-index: 2;
  pointer-events: none;
}

/* ---------------------------------------------------------- the side words

   Vertically centred against the viewport edges. The inset is a percentage
   rather than a rem so the words track the width of the sphere, which is also
   sized off the viewport. */
.works__side {
  position: absolute;
  top: 50%;
  transform: translateY(-50%);
  display: flex;
  flex-direction: column;
  line-height: 1.15;
  color: var(--white);
}

.works__side--l {
  left: 9%;
  font-family: var(--font-sans);
  font-size: 14px;
}

.works__side--r {
  right: 9%;
  text-align: right;
  font-family: var(--font-serif);
  font-style: italic;
  font-size: 15px;
}

/* A phone gives the sphere the full width and no more; words pinned to both
   edges would sit on top of it. The name is in the pill and the title anyway. */
@media (max-width: 640px) {
  .works__side { display: none; }
}

/* Each word is its own clipped box so the intro can slide it up from below
   independently, staggered. The inner span is injected by the overlay module
   the way text-reveal.js splits its lines elsewhere on the site; before that
   happens the word is simply a word, which is the correct resting state. */
.works__side-word {
  display: inline-block;
  overflow: hidden;
  vertical-align: bottom;
}

.works__side-word > span {
  display: inline-block;
  will-change: transform;
}

/* Parked below the clip during the intro and while an item is expanded. The
   default is visible: a stylesheet that hides content and waits for JS to
   reveal it leaves nothing behind when the JS fails. */
.works--intro .works__side-word > span,
.works--expanded .works__side-word > span {
  transform: translateY(100%);
  opacity: 0;
}

/* The same park, one level up, for the window in which there is no inner span
   to hold it. works.html ships the bare word — that is the point, it reads with
   no JS at all — and works-overlay.js injects the transform target when it
   mounts, which is after 690 kB of three.js has downloaded. Until then the rule
   above matches nothing and the word would paint at full strength, only to be
   snapped down the instant the overlay seeded it. The intro class is set by
   the parser (an inline script inside .works in works.html), so this covers
   the gap from the very first painted frame. Opacity only: the clip below is
   the thing that travels, and transforming it here as well would compose into
   a double translate the moment the span arrived. */
.works--intro .works__side-word {
  opacity: 0;
}

/* ------------------------------------------------------------- the label

   Title, subtitle and the overview paragraph, bottom-centre. The paragraph
   box is collapsed to zero height and clipped: opening an overview animates
   that height, which is also what lifts the expanded plane by half the same
   amount, so the pair stays visually balanced. */
.works__label {
  position: absolute;
  left: 50%;
  bottom: 20px;
  transform: translateX(-50%);
  width: min(90vw, 470px);
  text-align: center;
}

.works__title {
  margin: 0;
  font-family: var(--font-serif);
  font-size: 15px;
  line-height: 1.3;
  color: var(--white);
}

.works__subtitle {
  margin: 0;
  font-family: var(--font-sans);
  font-size: 12px;
  line-height: 1.4;
  color: var(--muted-lt);
}

/* Each label line gets its own clip so the reveal can slide the text up from
   under it, and out through the top on the way back, without a mask image.
   The two spans are injected by works-overlay.js the way text-reveal.js splits
   its lines elsewhere on the site; until then the two <p> elements are empty,
   which is the correct resting state — every readable word on this page also
   exists in .works__index. Parked below the clip rather than merely
   transparent: a fade with no travel is not the reference's reveal. */
.works__line {
  display: block;
  overflow: hidden;
}

.works__line > span {
  display: block;
  transform: translateY(100%);
  opacity: 0;
  will-change: transform;
}

.works__paragraph {
  max-width: 470px;
  height: 0;
  margin: 0 auto;
  overflow: hidden;
}

.works__paragraph-inner {
  padding-top: 12px;
  font-family: var(--font-sans);
  font-size: 13px;
  line-height: 1.5;
  color: var(--white);
}

/* --------------------------------------------------------------- counter

   Real load progress during the intro, then it slides out of its own clip and
   the label takes the same corner. They share the bottom centre because they
   are never on screen together. */
.works__counter {
  position: absolute;
  left: 50%;
  bottom: 16px;
  transform: translateX(-50%);
  overflow: hidden;
  font-family: var(--font-serif);
  font-size: 16px;
  line-height: 1.25;
  color: var(--white);
  /* Off unless there is load progress on screen. The two things that share this
     corner are never on screen together, and the counter is the one with a
     beginning and an end: it must not be there on a return visit (no intro to
     count), on the fallback grid, or sitting over the hover label for the rest
     of the session once it has slid away. Visibility rather than display so the
     element is laid out and its inner span can be transformed out of the clip
     before the class comes off.

     .works--loading and not .works--intro: the intro class is set by the parser
     in works.html, so the side words are never painted un-parked and then
     yanked back down — but a returning visitor skips the sequence, and
     hanging the counter on that same class would show them 001 for as long as
     the engine module takes to download. works-intro.js adds this one, only
     when it is actually going to count something. */
  visibility: hidden;
}

.works--loading .works__counter {
  visibility: visible;
}

.works__counter > span {
  display: inline-block;
  will-change: transform;
}

/* ------------------------------------------------------------------ chip

   The follow-up action, riding the pointer while an item is expanded. Fixed
   at the origin and moved entirely by transform — a top/left animation would
   lay out on every frame.

   The follow lag is ONE easing, and it belongs to works-overlay.js: a damp()
   per frame toward the pointer, plus a 0.45s scale-and-fade entrance. The
   element still carries [data-cursor-follow] from the markup, and styles.css
   puts `transition: transform .5s cubic-bezier(.165,.84,.44,1)` on that
   attribute — which is a SECOND easing on the same property, chasing a target
   the first one is already moving. Measured stacked: a dead frame at +60ms,
   the follow converging at ~1s instead of ~0.5s, and the entrance stretched
   to ~0.9s. The attribute stays (the markup is Task 3's, and the site's
   magnetic idiom is not running on this page anyway); the transition it
   brings with it does not.

   Below 1025px there is no pointer to follow, so the fixed Close and the
   bottom action button replace it. */
.works__chip {
  position: fixed;
  top: 0;
  left: 0;
  z-index: 3;
  transition: none;
  padding: 8px 14px;
  border-radius: 4px;
  background: rgb(245 245 245 / 0.08);
  -webkit-backdrop-filter: blur(24px);
  backdrop-filter: blur(24px);
  font-family: var(--font-sans);
  font-size: 12px;
  line-height: 1;
  color: var(--white);
  white-space: nowrap;
  opacity: 0;
  transform: scale(0.6);
  will-change: transform;
  pointer-events: none;
}

@media (max-width: 1024px) {
  .works__chip { display: none; }
}

/* -------------------------------------------------------- touch affordances

   Both exist only on the small breakpoint and only while something is
   expanded. They stay in the DOM rather than being created on demand so the
   overlay module has nothing to build.

   opacity + pointer-events rather than display: an element that has never
   been laid out cannot be transitioned in. */
/* Below the pill, not beside it. Measured at 390x844: the floating menu spans
   y 12..50 at z-index 60, and this button at top: 2rem sat squarely underneath
   it — elementFromPoint at its own centre returned the menu's logo SVG, so
   tapping Close navigated home instead of closing the item. 5.5rem clears the
   bar at every width this button exists at. */
.works__close {
  position: fixed;
  top: 5.5rem;
  left: 2rem;
  z-index: 3;
  padding: 0;
  font-family: var(--font-sans);
  font-size: 13px;
  color: var(--white);
  opacity: 0;
  pointer-events: none;
  transition: opacity 0.35s ease;
}

.works__action {
  position: fixed;
  left: 50%;
  bottom: calc(2rem + env(safe-area-inset-bottom, 0px));
  z-index: 3;
  transform: translateX(-50%);
  padding: 10px 18px;
  border-radius: 4px;
  background: rgb(245 245 245 / 0.08);
  -webkit-backdrop-filter: blur(24px);
  backdrop-filter: blur(24px);
  font-family: var(--font-sans);
  font-size: 12px;
  line-height: 1;
  color: var(--white);
  opacity: 0;
  pointer-events: none;
  transition: opacity 0.35s ease;
}

.works--expanded .works__close,
.works--expanded .works__action {
  opacity: 1;
  pointer-events: auto;
}

/* The same collision at the other end of the screen: the label, the counter
   and the action button all live bottom-centre, and on a phone the button's
   backdrop sat on top of the title it is supposed to caption. Lifted clear of
   it — and only while expanded, which is the only time the button is there. */
@media (max-width: 1024px) {
  .works--expanded .works__label {
    bottom: calc(5.5rem + env(safe-area-inset-bottom, 0px));
  }
}

@media (min-width: 1025px) {
  .works__close,
  .works__action { display: none; }
}

/* --------------------------------------------------------------- the index

   Hidden with the site's own .visually-hidden utility (styles.css) while the
   sphere runs — a canvas is empty to a crawler and to a screen reader, so the
   list is the page's actual content either way.

   .works--fallback is added by works-main.js when WebGL2 is unavailable or the
   visitor asked for reduced motion. Then the index stops being a mirror and
   becomes the page: released from the clip, back in the document flow, laid
   out as a grid, with body.is-scrollable turning native scrolling back on.

   The stage has to go, not just be emptied: it is fixed, full-viewport and
   carries touch-action: none, so leaving it above the index would swallow
   every click and every scroll gesture aimed at the grid. */
.works--fallback {
  position: static;
  height: auto;
  overflow: visible;
}

.works--fallback .works__stage,
.works--fallback .works__overlay {
  display: none;
}

.works--fallback .works__index {
  position: static;
  width: auto;
  height: auto;
  margin: 0;
  overflow: visible;
  clip: auto;
  clip-path: none;
  white-space: normal;
  /* Clears the floating pill: 1rem top inset + 3.125rem of bar. */
  padding: 6rem var(--padding) var(--padding);
}

/* How the index looks once it is on screen. Deliberately NOT prefixed with
   .works--fallback: two paths arrive here — that class, and the <noscript>
   block in works.html for the visitor whose browser never ran the module at
   all — and a rule only one of them can reach is a rule that will drift out
   of sync with the other. While the section is still clipped to 1×1 none of
   this renders, so it costs nothing to have it always on. */
.works__index h1 {
  margin: 0 0 var(--padding);
  font-family: var(--font-serif);
  font-weight: 400;
  font-size: var(--font-size-l);
  line-height: 1.1;
}

.works__index ul {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
  gap: var(--padding);
  margin: 0;
  padding: 0;
  list-style: none;
}

.works__index figure {
  margin: 0;
}

.works__index img {
  width: 100%;
  height: auto;
}

.works__index figcaption {
  margin-top: 0.5em;
  font-family: var(--font-sans);
  font-size: 12px;
  color: var(--muted-lt);
}

.works__index a {
  text-decoration: underline;
  text-underline-offset: 3px;
}

/* ------------------------------------------------------- reduced motion

   styles.css already collapses every transition duration site-wide under this
   query; these are the two properties it cannot reach, because they are
   resting states rather than durations. Reduced motion also routes the page
   into .works--fallback, where nothing below animates at all. */
@media (prefers-reduced-motion: reduce) {
  .works__chip {
    transform: none;
    will-change: auto;
  }
  .works__side-word > span,
  .works__line > span,
  .works__counter > span {
    will-change: auto;
  }
}
