/* =========================================================================
   Printed Wicked — shared stage & chrome
   -------------------------------------------------------------------------
   Everything here is prefixed `pw-` so it can be dropped onto a page that
   already has its own stylesheet without colliding. It sets no styles on
   bare elements except :root custom properties.

   A page opts in by adding class="pw-on-stage" to <body>. That makes the
   page's own background transparent so the stage shows through; without
   it, nothing below has any effect on existing layout.
   ========================================================================= */

:root {
  --pw-void: #050505;
  --pw-void-2: #0c0c0d;
  --pw-bone: #f2ede1;
  --pw-bone-dim: #a39c8c;
  --pw-bone-faint: #6c665c;
  --pw-brass: #c9a24b;
  --pw-brass-bright: #e8c874;
  --pw-brass-dim: #6b5726;
  --pw-rule: rgba(242, 237, 225, 0.14);

  --pw-display: "Cinzel", "Times New Roman", serif;
  --pw-brutal: "Archivo Black", "Arial Black", sans-serif;
  --pw-body: "Space Grotesk", system-ui, sans-serif;
  --pw-mono: "JetBrains Mono", ui-monospace, monospace;

  /* The chrome is two fixed bands: the nav strip, then the breadcrumb row
     under it. --pw-chrome-h is their combined height and is what a page
     pads itself by to clear them — always use that one for offsets, never
     --pw-strip-h alone, or the trail will cover the top of the page. */
  --pw-strip-h: 2.65rem;
  --pw-crumbs-h: 1.95rem;
  --pw-chrome-h: calc(var(--pw-strip-h) + var(--pw-crumbs-h));
}

/* ------------------------------------------------------------- accents */

/* One accent hue per project, chosen by a single class on <body>.

   The chrome — strip, crumbs, footer, focus ring — reads --pw-brass,
   --pw-brass-bright and --pw-brass-dim and nothing else, so re-pointing those
   three variables recolours all of it without touching a rule. The names stay
   "brass" on purpose: they are the site's default hue and renaming them would
   mean editing every rule that uses them for no gain.

   The dim value is the one that carries the work. It sits behind current-page
   underlines and panel borders, where a saturated hue reads as a mistake and a
   near-black reads as nothing; each is the accent taken down to roughly the
   same low luminance so the three themes weigh the same on the page.

   Pick by what the page already is, not by decoration. Frost came from the
   moonlight in the Wicked Pines plate; gold from the Oversoul painting; ember
   from the fact that a 3D viewer's own UI is grey and needs one warm signal
   colour that cannot be confused with geometry. */

.pw-accent-frost {           /* moonlight — Wicked Pines */
  --pw-brass: #a8cfd6;
  --pw-brass-bright: #d8eef2;
  --pw-brass-dim: #3f5c62;
  --pw-rule: rgba(200, 226, 230, 0.13);
}

.pw-accent-gold {            /* Oversoul */
  --pw-brass: #e8c04a;
  --pw-brass-bright: #ffe08c;
  --pw-brass-dim: #6d5518;
  --pw-rule: rgba(232, 192, 74, 0.14);
}

.pw-accent-xeno {            /* Necronom */
  --pw-brass: #4fd97a;
  --pw-brass-bright: #9dffc4;
  --pw-brass-dim: #1f5c38;
  --pw-rule: rgba(79, 217, 122, 0.14);
}

/* Dopesmoker. Taken from the plant in the backdrop rather than picked: the
   fan leaves go deep purple at the tips while the buds stay pale green, and
   this is that purple pushed up until it can carry a 0.66rem link on near
   black. It replaced Oversoul's gold when the page was rethemed.

   Deliberately NOT --pw-link violet, which Loopsmith already owns — two
   projects answering in the same colour is exactly what the per-destination
   hue system exists to avoid. This one is redder and darker. */
.pw-accent-orchid {          /* Dopesmoker */
  --pw-brass: #b563d8;
  --pw-brass-bright: #dba6f2;
  --pw-brass-dim: #5a2c6e;
}

.pw-accent-forge {           /* Full Heart Forge */
  /* The fire, sampled straight off the emblem at 33% of the frame. It lands
     15 degrees from ember, which the sampler flags and Amir overrode on
     purpose - it is the colour of the artwork and no other candidate is.
     Worth knowing the two sit together in the nav dropdowns and on the index
     roster; if they ever read as one colour, warming this a few degrees
     separates them without changing its character. */
  --pw-brass: #cc6a28;
  /* The sampler proposed #da9e76 for bright, which is a muted salmon rather
     than a lift of anything. It measured 3.02:1 where the eyebrow sits over
     the emblem's fire on a phone - under the 4.5:1 that 11px text needs. This
     is the same hue taken up until it clears, at 4.68:1 there. */
  --pw-brass-bright: #ffc9a8;
  --pw-brass-dim: #53331d;
  --pw-rule: rgba(204, 106, 40, 0.14);
}

.pw-accent-crimson {         /* Horimukade */
  /* Sampled off the artwork's robe, then rotated 17 degrees colder. The raw
     sample came back at #ef4c41, three degrees from ember - close enough that
     the two destinations would have answered in what reads as the same colour,
     which is the one thing the per-destination hue system exists to prevent.
     This is a blood crimson where ember is a coral orange, and 4.92:1 on the
     void, so it still clears body text. */
  --pw-brass: #e63950;
  --pw-brass-bright: #ff8fa3;
  --pw-brass-dim: #6e1526;
  --pw-rule: rgba(230, 57, 80, 0.14);
}

.pw-accent-ember {           /* Mesh Viewer */
  --pw-brass: #e4614a;
  --pw-brass-bright: #ff9078;
  --pw-brass-dim: #6d2b1f;
  --pw-rule: rgba(228, 97, 74, 0.14);
}

/* Lumen came from what the page is NOT. Pegsmith puts eight saturated peg
   colours on screen at once, and any accent drawn from that set would read
   as one of them meaning something — a red strip over a design with red
   pegs in it looks like a selection. Cyan is the one bright hue the peg set
   does not contain, so the chrome can never be mistaken for the artwork. */
.pw-accent-lumen {           /* Pegsmith */
  --pw-brass: #2fd6c8;
  --pw-brass-bright: #86f2e9;
  --pw-brass-dim: #1a6660;
  --pw-rule: rgba(47, 214, 200, 0.14);
}

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

.pw-on-stage {
  background: var(--pw-void) !important;
  color: var(--pw-bone);
}

/* z-index: -1 puts the stage behind ordinary page content without the
   page needing to position anything. An earlier version kept it at 0 and
   lifted every sibling with `.pw-on-stage > *`, but that rule carried two
   classes of specificity and silently beat `.pw-strip { position: fixed }`
   — the fixed utility strip started scrolling with the page. Pushing the
   stage back one layer removes the need for the rule at all. */
.pw-stage {
  position: fixed;
  inset: 0;
  z-index: -1;
  pointer-events: none;
  background: var(--pw-void);
}

/* Faint sheen, so flat black still reads as glossy. */
.pw-stage::after {
  content: "";
  position: fixed;
  inset: 0;
  pointer-events: none;
  background:
    radial-gradient(120% 90% at 15% -10%, rgba(255, 255, 255, 0.05), transparent 45%),
    radial-gradient(140% 100% at 100% 120%, rgba(201, 162, 75, 0.06), transparent 55%);
}

.pw-dust {
  position: fixed;
  inset: 0;
  width: 100%;
  height: 100%;
  display: block;
}

/* Same treatment as the index: cropped with `cover` and anchored right,
   because the skull's mass sits at 90-95% of the frame's width and must
   survive a narrow viewport. `screen` drops the dark areas so the dust
   behind shows through. The mask dissolves the frame's top edge, which
   would otherwise cut the atmospheric haze off in a hard line. */
.pw-video {
  position: fixed;
  bottom: 0;
  left: 0;
  width: 100%;
  height: 88vh;
  height: 88dvh;
  min-height: 240px;
  object-fit: cover;
  object-position: right bottom;
  mix-blend-mode: screen;
  filter: saturate(1.04);
  transform-origin: bottom center;
  /* The scale buys headroom for the parallax shift; without it the
     translate would expose an edge. */
  transform: translate3d(calc(var(--pw-par-x, 0) * -1.3%), 0, 0) scale(1.045);
  will-change: transform;
  -webkit-mask-image: linear-gradient(to top, #000 78%, transparent 100%);
  mask-image: linear-gradient(to top, #000 78%, transparent 100%);
}

@media (prefers-reduced-motion: reduce) {
  .pw-video { transform: scale(1.045); }
}

/* --------------------------------------------------------- glass surfaces */

/* The join between a project's UI and the stage. Panels are translucent
   and blurred so the sand and dust stay legible behind them, rather than
   sitting on an opaque slab that cancels the background out. */
.pw-panel {
  background: rgba(10, 10, 13, 0.72);
  -webkit-backdrop-filter: blur(14px) saturate(1.1);
  backdrop-filter: blur(14px) saturate(1.1);
  border: 1px solid var(--pw-rule);
}

.pw-panel--solid {
  background: rgba(6, 6, 8, 0.9);
}

/* backdrop-filter is not universal; without it a translucent panel over a
   busy background is unreadable, so fall back to something opaque. */
@supports not ((backdrop-filter: blur(1px)) or (-webkit-backdrop-filter: blur(1px))) {
  .pw-panel { background: rgba(8, 8, 10, 0.94); }
}

/* ------------------------------------------------------- utility strip */

/* Deliberately slim. Every project page already owns its top-left and
   top-right corners (a drawer toggle, a masthead, an admin button), so
   this sits above them as a site-level band rather than competing. */
.pw-strip {
  position: fixed;
  top: 0;
  left: 0;
  right: 0;
  z-index: 60;
  height: var(--pw-strip-h);
  display: flex;
  align-items: center;
  gap: clamp(0.5rem, 2vw, 1.25rem);

  /* The five below look redundant — they are the defaults — but they are
     load-bearing. This element is a <header>, and a project page that
     styles its own masthead with a bare `header { ... }` selector will
     hit this one too. `.pw-strip` (0,1,0) outranks `header` (0,0,1), so
     the page only wins on properties we never declare.

     Loopsmith did exactly that: its `header { justify-content:
     space-between }` shoved the site links to the far right on that page
     alone, because alignment was the one thing this rule left unsaid.
     Declaring the defaults closes the gap. Pages have been scoped at the
     source too, but this file cannot depend on every future page
     remembering. */
  justify-content: flex-start;
  flex-direction: row;
  flex-wrap: nowrap;
  margin: 0;
  width: auto;

  /* Same reasoning, different mechanism: `height` plus a border means the
     strip is 1px taller on a page whose reset is content-box than on one
     using `* { box-sizing: border-box }`. Stating it here makes the band
     exactly --pw-strip-h everywhere, which matters because that height
     feeds --pw-chrome-h, the number every page pads itself by. */
  box-sizing: border-box;
  padding: 0 clamp(0.7rem, 2.5vw, 1.5rem);
  background: rgba(5, 5, 5, 0.82);
  -webkit-backdrop-filter: blur(10px);
  backdrop-filter: blur(10px);
  border-bottom: 1px solid var(--pw-rule);
  font-family: var(--pw-mono);
  font-size: 0.66rem;
  letter-spacing: 0.14em;
  text-transform: uppercase;
}

.pw-strip__home {
  color: var(--pw-bone);
  text-decoration: none;
  font-family: var(--pw-display);
  font-weight: 900;
  font-size: 0.82rem;
  letter-spacing: 0.12em;
  white-space: nowrap;
  flex: 0 0 auto;
}

.pw-strip__home:hover,
.pw-strip__home:focus-visible { color: var(--pw-brass); }

.pw-strip__links {
  display: flex;
  align-items: center;
  gap: clamp(0.5rem, 1.8vw, 1.1rem);
  overflow-x: auto;
  scrollbar-width: none;
  -ms-overflow-style: none;
  min-width: 0;
  /* No edge fade here on purpose. A mask looks right while the row
     overflows, but CSS cannot tell whether it does — so on a wide screen
     it just shaved the first and last link. Plain overflow scrolling is
     less clever and always correct. */
}

.pw-strip__links::-webkit-scrollbar { display: none; }

/* Every nav link carries the colour of the place it GOES TO, not the colour
   of the page it is being viewed from.

   It used to be the second thing: the hover colour came from --pw-brass,
   which the .pw-accent-* class on <body> re-points per page, so all six
   links turned red on Mesh Viewer and green on Necronom. Consistent, and
   useless — six links that all light up identically tell you nothing about
   which one you are pointing at, and the same link answering in a different
   colour depending on where you happen to be standing is not an accent, it
   is noise.

   The mechanism is three local custom properties. They default to the page
   accent, so a link with no .pw-hue-* class behaves exactly as it did, and
   the hover and current rules are written once against the locals instead of
   being repeated per colour. nav.php attaches the classes; the hues are
   listed below. */
.pw-strip__link {
  --pw-link:        var(--pw-brass);
  --pw-link-bright: var(--pw-brass-bright);
  --pw-link-dim:    var(--pw-brass-dim);

  color: var(--pw-bone-dim);
  text-decoration: none;
  white-space: nowrap;
  flex: 0 0 auto;
  padding: 0.15rem 0;
  border-bottom: 1px solid transparent;
}

.pw-strip__link:hover,
.pw-strip__link:focus-visible {
  color: var(--pw-link-bright);
  border-bottom-color: var(--pw-link);
}

/* "You are here" now also answers in the destination's own colour, which on
   an accented page is the colour it already was — the accent and the hue
   agree by construction — and on Loopsmith or Stick Battle is the first
   thing on the page that is theirs. */
.pw-strip__link[aria-current="page"] {
  color: var(--pw-link);
  border-bottom-color: var(--pw-link-dim);
}

/* The hues. Four are lifted straight from the destination's own
   .pw-accent-* block above, so a project and its link agree. Azure is Wicked
   Pines' masthead blue rather than the paler frost of its page chrome —
   frost is moonlight sitting on a photograph and has nowhere near the
   contrast to mark a 0.62rem link on a near-black bar. Violet and rose are
   assigned rather than derived: those two projects have no palette yet, and
   these should change to whatever they turn out to be.

   Deliberately NOT scoped under .pw-strip__link. The same classes work on
   any link that wants to carry a destination's colour — the footer, a link
   in body copy — and scoping them now means writing them twice later. */
.pw-hue-ember  { --pw-link: #e4614a; --pw-link-bright: #ff9078; --pw-link-dim: #6d2b1f; }
.pw-hue-gold   { --pw-link: #e8c04a; --pw-link-bright: #ffe08c; --pw-link-dim: #6d5518; }
.pw-hue-xeno   { --pw-link: #4fd97a; --pw-link-bright: #9dffc4; --pw-link-dim: #1f5c38; }
.pw-hue-azure  { --pw-link: #2f9bff; --pw-link-bright: #7cc6ff; --pw-link-dim: #1a4a78; }
.pw-hue-violet { --pw-link: #a97bf0; --pw-link-bright: #cdb0ff; --pw-link-dim: #46306e; }
.pw-hue-rose   { --pw-link: #ff5f9e; --pw-link-bright: #ffa3c6; --pw-link-dim: #73264a; }
.pw-hue-orchid { --pw-link: #b563d8; --pw-link-bright: #dba6f2; --pw-link-dim: #5a2c6e; }
.pw-hue-crimson{ --pw-link: #e63950; --pw-link-bright: #ff8fa3; --pw-link-dim: #6e1526; }
.pw-hue-forge  { --pw-link: #cc6a28; --pw-link-bright: #ffc9a8; --pw-link-dim: #53331d; }
.pw-hue-lumen  { --pw-link: #2fd6c8; --pw-link-bright: #86f2e9; --pw-link-dim: #1a6660; }

/* The brand is the index's own link, so it takes the same "you are here"
   treatment there — otherwise the one page with no highlighted link would
   be the one page you were on. */
.pw-strip__home[aria-current="page"] { color: var(--pw-brass); }

/* ------------------------------------------------------------ breadcrumbs */

/* Its own band, directly under the strip. The first version tucked the
   trail into the right-hand end of the strip; it fit, but it read as
   clutter — the site nav and "where am I" are different questions, and
   sharing one line made neither easy to scan.

   Sitting at top: var(--pw-strip-h) rather than being a flex row inside
   the strip keeps the strip's height fixed and independent, so a long
   trail can never push the nav around. */
.pw-crumbs {
  position: fixed;
  top: var(--pw-strip-h);
  left: 0;
  right: 0;
  z-index: 59;              /* just under the strip, above page content */
  box-sizing: border-box;
  height: var(--pw-crumbs-h);
  display: flex;
  align-items: center;
  /* Same horizontal inset as the strip, so the wordmark above and the
     first crumb below share one left edge. */
  padding: 0 clamp(0.7rem, 2.5vw, 1.5rem);
  background: rgba(5, 5, 5, 0.72);
  -webkit-backdrop-filter: blur(10px);
  backdrop-filter: blur(10px);
  border-bottom: 1px solid var(--pw-rule);
  font-family: var(--pw-mono);
  font-size: 0.6rem;
  letter-spacing: 0.12em;
  text-transform: uppercase;
  overflow: hidden;
}

.pw-crumbs__list {
  display: flex;
  align-items: center;
  gap: 0.4rem;
  margin: 0;
  padding: 0;
  list-style: none;
  min-width: 0;
}

.pw-crumbs__item {
  display: flex;
  align-items: center;
  gap: 0.4rem;
  min-width: 0;
  white-space: nowrap;
}

/* The separator is generated content, so it is decoration a screen reader
   skips rather than a stray "slash" read between every crumb. */
.pw-crumbs__item + .pw-crumbs__item::before {
  content: "/";
  color: var(--pw-bone-faint);
}

.pw-crumbs__link {
  color: var(--pw-bone-dim);
  text-decoration: none;
}

.pw-crumbs__link:hover,
.pw-crumbs__link:focus-visible { color: var(--pw-brass-bright); }

.pw-crumbs__here {
  color: var(--pw-brass);
  /* A model slug can be long. Let the last crumb be the one that gives —
     the ancestors above it are short and fixed. */
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
  min-width: 0;
}

/* ----------------------------------------------------- strip: dropdowns */

/* Added with the 2026-08 re-org, when six sibling links became three
   categories each holding its projects.

   HOW IT OPENS depends on whether the script ran.

   WITH the script (html.pw-js): :hover for a pointer, and [data-open] for
   everything else — the caret is a real disclosure button, so a tap opens it
   and Escape or a click elsewhere closes it.

   WITHOUT it: :hover, plus :focus-within so a keyboard can still reach the
   links by tabbing. The caret ships hidden and is revealed by the script,
   because a control that cannot respond to a tap is worse than no control.

   :focus-within is deliberately NOT used when the script is present, and
   this is the whole reason the two cases are split. Clicking the caret
   leaves focus on the caret, which is inside the group — so :focus-within
   was holding the menu open after Escape had already removed [data-open],
   and the menu could not be dismissed without moving focus out of it
   entirely. Under the script, [data-open] is the single source of truth. */

.pw-strip__group {
  position: relative;
  display: flex;
  align-items: center;
  gap: 0.15rem;
  flex: 0 0 auto;
}

.pw-strip__caret {
  appearance: none;
  background: none;
  border: 0;
  padding: 0.1rem 0.15rem;
  margin: 0;
  line-height: 1;
  font-size: 0.6rem;
  color: var(--pw-bone-faint);
  cursor: pointer;
  transition: transform 0.14s ease, color 0.14s ease;
}

.pw-strip__group:hover .pw-strip__caret,
.pw-strip__caret:focus-visible { color: var(--pw-brass); }
.pw-strip__group[data-open] .pw-strip__caret { transform: rotate(180deg); }

/* Not display:none when closed. A menu that is removed from the box tree
   cannot be measured, cannot animate, and — the part that actually bites —
   drops its links out of the tab order in a way that makes :focus-within
   unable to ever fire, so the keyboard route to opening it would depend on
   the menu already being open. visibility does the hiding instead: it takes
   the links out of the tab order and off the screen while leaving the box
   in place, so focus can still reach it the moment the group is entered. */
.pw-menu {
  position: absolute;
  top: 100%;
  left: -0.55rem;
  z-index: 2;
  margin: 0;
  padding: 0.4rem 0;
  list-style: none;
  min-width: max(9rem, 100%);

  background: rgba(8, 8, 10, 0.96);
  -webkit-backdrop-filter: blur(12px);
  backdrop-filter: blur(12px);
  border: 1px solid var(--pw-rule);
  border-top: 0;
  box-shadow: 0 12px 28px rgba(0, 0, 0, 0.55);

  opacity: 0;
  visibility: hidden;
  transform: translateY(-3px);
  transition: opacity 0.14s ease, transform 0.14s ease, visibility 0s linear 0.14s;
}

.pw-strip__group:hover > .pw-menu,
.pw-strip__group[data-open] > .pw-menu,
html:not(.pw-js) .pw-strip__group:focus-within > .pw-menu {
  opacity: 1;
  visibility: visible;
  transform: none;
  transition-delay: 0s;
}

.pw-menu__link {
  display: block;
  padding: 0.4rem 1rem;
  color: var(--pw-bone-dim);
  text-decoration: none;
  white-space: nowrap;

  --pw-link:        var(--pw-brass);
  --pw-link-bright: var(--pw-brass-bright);
  --pw-link-dim:    var(--pw-brass-dim);
}

.pw-menu__link:hover,
.pw-menu__link:focus-visible {
  color: var(--pw-link-bright);
  background: rgba(242, 237, 225, 0.05);
}

.pw-menu__link[aria-current="page"] {
  color: var(--pw-link);
  box-shadow: inset 2px 0 0 var(--pw-link-dim);
}

/* The strip is a horizontal scroller, and an absolutely positioned child of
   a scrolling box is clipped by it. Letting the row overflow visibly is only
   safe once the links certainly fit — which on a wide screen they do, three
   of them. Below that the panel takes over and the row is hidden anyway. */
@media (min-width: 720px) {
  .pw-strip__links { overflow: visible; }
}

/* --------------------------------------------------- strip: narrow screen */

/* Both JS-only affordances are gated on .pw-js, which the script in nav.php
   sets on <html> as its first act. With scripts off, the burger never appears
   and the category row stays — scrollable, as it always was. */

.pw-strip__burger {
  display: none;
  appearance: none;
  margin-left: auto;
  flex: 0 0 auto;
  padding: 0.3rem 0.6rem;
  background: none;
  border: 1px solid var(--pw-rule);
  color: var(--pw-bone-dim);
  font: inherit;
  letter-spacing: 0.14em;
  text-transform: uppercase;
  cursor: pointer;
}

.pw-strip__burger:hover,
.pw-strip__burger:focus-visible {
  color: var(--pw-brass-bright);
  border-color: var(--pw-brass-dim);
}

@media (max-width: 719px) {
  .pw-js .pw-strip__links { display: none; }
  .pw-js .pw-strip__burger { display: inline-flex; }
}

/* The panel hangs below both fixed bands rather than replacing them, so the
   breadcrumb stays visible and the page does not appear to jump. */
.pw-nav-panel {
  position: fixed;
  top: var(--pw-chrome-h);
  left: 0;
  right: 0;
  z-index: 59;
  max-height: calc(100dvh - var(--pw-chrome-h));
  overflow-y: auto;
  padding: 0.9rem clamp(0.7rem, 2.5vw, 1.5rem) 1.4rem;

  background: rgba(8, 8, 10, 0.97);
  -webkit-backdrop-filter: blur(12px);
  backdrop-filter: blur(12px);
  border-bottom: 1px solid var(--pw-rule);
  font-family: var(--pw-mono);
  font-size: 0.72rem;
  letter-spacing: 0.12em;
  text-transform: uppercase;
}

/* A scrim over whatever is below the panel. Without it the page shows in the
   gap under a short menu, and a half-sentence of body copy peeking out reads
   as the panel having failed to open rather than as depth.

   z-index:-1 puts it behind the panel's own background but still inside the
   panel's stacking context, which the panel established with its own z-index
   — so it covers the page without covering the menu. Clicks on it are handled
   by the same click-outside listener that any other part of the page uses. */
.pw-nav-panel::after {
  content: "";
  position: fixed;
  inset: 0;
  z-index: -1;
  background: rgba(5, 5, 5, 0.6);
}

.pw-nav-panel__group + .pw-nav-panel__group {
  margin-top: 0.9rem;
  padding-top: 0.9rem;
  border-top: 1px solid var(--pw-rule);
}

.pw-nav-panel__head {
  display: block;
  color: var(--pw-bone);
  text-decoration: none;
  font-family: var(--pw-display);
  font-size: 0.95rem;
  letter-spacing: 0.1em;
}

.pw-nav-panel__head[aria-current="page"] { color: var(--pw-brass); }

.pw-nav-panel__list {
  margin: 0.5rem 0 0;
  padding: 0;
  list-style: none;
  display: flex;
  flex-direction: column;
}

.pw-nav-panel__link {
  display: block;
  padding: 0.5rem 0 0.5rem 0.9rem;
  color: var(--pw-bone-dim);
  text-decoration: none;
  border-left: 1px solid var(--pw-rule);

  --pw-link:        var(--pw-brass);
  --pw-link-bright: var(--pw-brass-bright);
  --pw-link-dim:    var(--pw-brass-dim);
}

.pw-nav-panel__link:hover,
.pw-nav-panel__link:focus-visible {
  color: var(--pw-link-bright);
  border-left-color: var(--pw-link);
}

.pw-nav-panel__link[aria-current="page"] {
  color: var(--pw-link);
  border-left-color: var(--pw-link-dim);
}

/* ------------------------------------------------------------- utility */

/* Visible to assistive technology, invisible on screen. Used where artwork
   stands in for text that still has to exist - the collaborator <h1>, where
   a traced logo replaces the look of the heading but not the heading. */
.pw-vh {
  position: absolute !important;
  width: 1px; height: 1px;
  margin: -1px; padding: 0;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
  border: 0;
}

/* -------------------------------------------------- collaborator pages */

/* /collaborations/<name>/. Built by includes/collaborator.php, driven by
   /assets/pw/collab.js. Dopesmoker is the first; the rest write a config and
   inherit all of this.

   THE STACK, back to front:
     __inner   the plant footage, overscaled and panned with the pointer
     __haze    smoke, panned FURTHER for the same input - the depth cue
     __veil    a dark wash so the copy on top of it can be read
   Everything in the stage is scenery: aria-hidden in the markup, and
   pointer-events:none here so it can never eat a click meant for the page. */

.pw-collab-stage {
  position: fixed;
  inset: 0;
  z-index: -1;
  overflow: hidden;          /* the overscaled layers are clipped to the window */
  pointer-events: none;
  background: var(--pw-void);
}

/* The transformed element. will-change because it changes every frame while
   the pointer moves; without it the browser re-rasterises the video layer on
   each step instead of just re-compositing it. */
.pw-collab-stage__inner,
.pw-collab-stage__haze {
  position: absolute;
  inset: 0;
  will-change: transform;
  transform-origin: center center;
  backface-visibility: hidden;
}

/* ------------------------------------------------- the pattern backdrop

   An alternative to a photographic plate: a tiled motif behind everything,
   the FURTHEST layer on the page.

   The tile ships as white line art with an alpha channel and no colour of its
   own, so the gold below is a CSS value rather than something baked into the
   file. That is not tidiness - it is what let the tile drop from 135 KB to
   8 KB, because a flat white plane plus an alpha mask is almost nothing to
   compress, and it means the colour and the strength are tunable without
   re-exporting the art.

   background-color paints THROUGH the mask via -webkit-mask/mask, rather than
   the image being drawn directly: same reason. */
.pw-collab-stage__pattern {
  position: absolute;
  /* Bigger than the window on every side so the pan and the zoom-breath can
     never bring an edge into frame. A repeating tile has no seam, but it does
     have a bounding box. */
  inset: -18%;
  will-change: transform;
  transform-origin: center center;
  backface-visibility: hidden;
  background-color: var(--pw-pattern-ink, #e4a836);
  -webkit-mask-image: var(--pw-pattern-src);
          mask-image: var(--pw-pattern-src);
  -webkit-mask-repeat: repeat;
          mask-repeat: repeat;
  -webkit-mask-size: var(--pw-pattern-size, 26rem) var(--pw-pattern-size, 26rem);
          mask-size: var(--pw-pattern-size, 26rem) var(--pw-pattern-size, 26rem);
  opacity: var(--pw-pattern-opacity, 0.46);
}

/* -------------------------------------------------------- the grain layer

   A rock texture laid over the stage's own black, before anything else. It is
   not scenery - it is there to stop a large flat black area reading as a
   rendering fault on an OLED screen, where "black" is genuinely no light at
   all and a gradient across it bands visibly.

   THE TILE IS SYNTHESISED FROM THE PHOTOGRAPH, NOT CROPPED FROM IT. The
   supplied granite is a photograph, and a photograph does not tile: its slow
   lighting shading repeats as blotches on a visible grid. High-passing it
   removes the shading, but the seams remain - and patching those by rolling
   the tile and cross-fading leaves a darker cross through the middle, because
   feathering two copies of noise together halves its amplitude where they
   overlap. What ships instead keeps the real granite's MAGNITUDE SPECTRUM and
   randomises the phase: same grain size, same roughness, same stone, and
   periodic by construction because a DFT is periodic. There is no seam.

   White with an alpha channel and no colour, so the tint and the strength are
   CSS values rather than baked in.

   The mask is the point of the whole layer: full strength in one corner,
   gone by the middle, so it never becomes a texture the eye reads across the
   whole frame. */
.pw-collab-stage__grain {
  position: absolute;
  inset: 0;
  pointer-events: none;
  background-color: var(--pw-grain-ink, #cdd0d6);
  -webkit-mask-image: var(--pw-grain-src), var(--pw-grain-fade);
          mask-image: var(--pw-grain-src), var(--pw-grain-fade);
  -webkit-mask-repeat: repeat, no-repeat;
          mask-repeat: repeat, no-repeat;
  -webkit-mask-size: var(--pw-grain-size, 16rem) var(--pw-grain-size, 16rem), 100% 100%;
          mask-size: var(--pw-grain-size, 16rem) var(--pw-grain-size, 16rem), 100% 100%;
  /* The grain supplies the speckle, the gradient supplies the corner fade, and
     they have to INTERSECT - added, the gradient alone would paint a solid
     wash over the corner. */
  -webkit-mask-composite: source-in;
          mask-composite: intersect;
  opacity: var(--pw-grain-opacity, 0.10);
}

/* ---------------------------------------------------- the standing figure

   For artwork that is a CUT-OUT rather than a plate - a transparent PNG of a
   subject with no background of its own.

   It is a child of __inner, so it inherits that layer's pointer pan and zoom
   for free and needs no transform of its own. Sized from the HEIGHT with
   width:auto, because the whole point is that the subject stands in the frame
   at a known size; letting the width drive it would make the figure grow and
   shrink with the window's aspect instead of its height.

   Why this is not one more `object-fit: cover` plate: a cover crop of a
   portrait subject in a landscape window throws away the sides, and on this
   page the lantern IS the right-hand side. Positioning the cut-out directly
   is what keeps the whole subject in frame at every window shape. */
.pw-collab-stage__figure {
  position: absolute;
  left: var(--pw-fig-x, 76%);
  top: var(--pw-fig-y, 52%);
  height: var(--pw-fig-h, 92%);
  aspect-ratio: var(--pw-fig-ratio, 985 / 1786);
  transform: translate(-50%, -50%);

  /* Off by default. A cut-out on transparency has no rectangle to hide, and
     fading its edges would eat the art - a bird's tail, the tip of a scythe.

     It is needed when the artwork is a BADGE ON ITS OWN BLACK rather than a
     cut-out: that black is never exactly the page's black (Full Heart Forge's
     bottom-right corner is a brown 70,54,40 where the rope reaches it), so
     the frame shows as a faint rectangle. Fading the outer few percent
     dissolves it without touching the middle.

     A mask rather than a baked alpha channel, so the still and the video are
     covered by one rule instead of needing two processed assets. */
  -webkit-mask-image: var(--pw-fig-fade, none);
          mask-image: var(--pw-fig-fade, none);
  /* Stated unconditionally: with mask-image:none it does nothing, and the two
     gradients above have to INTERSECT rather than add or the corners are the
     only part that fades. Not an HTML attribute, which is where the first
     attempt at this tried to put it. */
  -webkit-mask-composite: source-in;
          mask-composite: intersect;
}

.pw-collab-stage__figure img,
.pw-collab-stage__figvid {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  display: block;
}

/* An animated cut-out. Stacked over its own still and faded in on canplay,
   so the artwork is there from the first paint and the clip replaces it
   without a jump. object-fit: fill rather than cover, deliberately: the
   figure's box already carries the art's exact aspect ratio, so there is
   nothing to crop and cover would only introduce rounding. */
.pw-collab-stage__figvid {
  object-fit: fill;
  opacity: 0;
  transition: opacity 900ms ease;
}
.pw-collab-stage__figvid.is-ready { opacity: 1; }

/* ------------------------------------------------------------- the glows

   Light sources in the artwork - a lantern, a pair of eye sockets - lit as
   page layers instead of being painted into the file.

   THEY ARE CHILDREN OF THE FIGURE, and every coordinate is a percentage of
   the figure's own box. That is the whole reason this works: the figure moves
   and scales with the pointer, and a glow positioned against the VIEWPORT
   would slide off the socket it belongs to the moment anything moved. Against
   the figure's box it cannot.

   mix-blend-mode: screen so a glow LIFTS what is under it the way light does,
   rather than painting an opaque disc over the drawing. */
.pw-collab-stage__glow {
  position: absolute;
  left: var(--gx);
  top: var(--gy);
  width: var(--gw);
  height: var(--gh);
  transform: translate(-50%, -50%);
  mix-blend-mode: screen;
  pointer-events: none;
  background:
    radial-gradient(closest-side ellipse,
      var(--g3, transparent) 0%, transparent 100%),
    radial-gradient(closest-side ellipse,
      var(--g2, transparent) 0%, transparent 38%),
    radial-gradient(closest-side ellipse,
      var(--g1, transparent) 0%, transparent 13%);
}

/* Fades in, beats a few times, fades out, rests. The rest at the end is what
   stops it reading as a blinking indicator: a light that pulses forever is a
   status LED, one that comes and goes is something alive.

   Both sockets carry the same animation with no offset - they are one face,
   and staggering them made it read as two separate lamps. */
.pw-collab-stage__glow--pulse {
  opacity: 0;
  animation: pw-collab-glow-pulse var(--gdur, 9s) ease-in-out infinite;
  animation-delay: var(--gdelay, 0s);
}

/* Flame. Not a sine - a lamp that breathes in and out on a smooth curve
   reads as a slow strobe, because the eye finds the period immediately. The
   stops below are deliberately uneven in both time and depth, and each shell
   of the lamp is given a different duration in the page config, so the three
   never resynchronise into something countable. Shallow on purpose: this is
   paper in front of a flame, not a fault in the wiring. */
.pw-collab-stage__glow--flicker {
  animation: pw-collab-glow-flicker var(--gflicker, 3.1s) ease-in-out infinite;
}

@keyframes pw-collab-glow-flicker {
  0%, 100% { opacity: 1; }
  6%   { opacity: 0.93; }
  11%  { opacity: 1; }
  19%  { opacity: 0.87; }
  24%  { opacity: 0.97; }
  31%  { opacity: 0.91; }
  38%  { opacity: 1; }
  45%  { opacity: 0.85; }
  50%  { opacity: 0.96; }
  57%  { opacity: 0.92; }
  64%  { opacity: 1; }
  71%  { opacity: 0.88; }
  78%  { opacity: 0.98; }
  86%  { opacity: 0.93; }
  93%  { opacity: 1; }
}

@keyframes pw-collab-glow-pulse {
  0%              { opacity: 0; }
  14%             { opacity: 1; }
  22%             { opacity: 0.58; }
  30%             { opacity: 1; }
  38%             { opacity: 0.64; }
  46%             { opacity: 1; }
  54%             { opacity: 0.56; }
  62%             { opacity: 1; }
  80%, 100%       { opacity: 0; }
}

.pw-collab-stage__still,
.pw-collab-stage__video {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  object-fit: cover;
  display: block;
}

/* The video sits on top of the still and fades in when it can actually play,
   so a slow connection shows the photograph rather than black, and a fast one
   never shows the swap. */
.pw-collab-stage__video {
  opacity: 0;
  transition: opacity 900ms ease;
}
.pw-collab-stage__video.is-ready { opacity: 1; }

.pw-collab-stage__haze {
  /* WebP: this is a soft-edged alpha texture with no hard detail, so it
     compresses to about half the PNG with nothing visible lost, and every
     browser that can run the rest of this page can decode it. */
  background-image: url('/assets/pw/smoke.webp');
  background-size: cover;
  background-position: center;
  /* Per-page, defaulting to where it has always been. Raised on the pages
     that want more air in the room; it is the one number that decides whether
     this layer reads as atmosphere or as a slight film. */
  opacity: var(--pw-haze-opacity, 0.30);
  mix-blend-mode: screen;    /* smoke adds light; it does not paint over */
}

/* Three jobs in one element, and the first is not decorative.

   MEASURED, not eyeballed. The copy is --pw-bone-dim on top of a photograph
   of sunlit foliage, and at the first pass that came out at 2.6:1 — under the
   4.5:1 that body text needs. The left scrim is what fixes it: the text column
   lives in the left half, so that half is taken down hard while the right side
   stays open and the picture still reads as a picture. Re-measure if the
   gradients are ever retuned; a backdrop that looks fine at a glance can still
   be failing the copy sitting on it.

   The vertical gradient gives the fixed chrome and the foot of the page
   something to sit on. The radial is a vignette that pulls the eye in and
   hides the plant photo being brightest at its edges. */
.pw-collab-stage__veil {
  position: absolute;
  inset: 0;
  background:
    linear-gradient(to right,
      rgba(5,5,5,.20) 0,
      rgba(5,5,5,.60) 26%,
      rgba(5,5,5,.50) 55%,
      rgba(5,5,5,.00) 78%,
      rgba(5,5,5,.40) 100%),
    linear-gradient(to bottom,
      rgba(5,5,5,.20) 0,
      rgba(5,5,5,.52) 18%,
      rgba(5,5,5,.10) 45%,
      rgba(5,5,5,.58) 82%,
      rgba(5,5,5,.84) 100%),
    radial-gradient(120% 85% at 50% 45%, transparent 30%, rgba(5,5,5,.62) 100%);
}

/* The creature's canvas. Full-bleed and on top of the veil, but still inside
   the stage - so .pw-collab__main (z-index 1) is above it and it can never
   crawl across the words. pointer-events:none because it is the one element
   here that sits over the whole viewport, and it must not eat a click. */
.pw-collab-stage__crawler {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  display: block;
  pointer-events: none;
}

/* A CUT-OUT STAGE NEEDS LESS VEIL THAN A PLATE.

   The gradients above are measured against a photograph of sunlit foliage -
   a bright, high-contrast, full-frame image that has to be taken down hard
   before body copy can sit on it. A pattern-and-figure stage is already
   mostly dark ground: the same veil over it crushes the artwork to a
   silhouette and puts the lantern out, which is the one thing on the page
   that is supposed to be lit.

   So this is the same SHAPE - weighted to the left, where the copy column is
   - just lighter, and re-measured rather than eyeballed. Body copy comes out
   at 8.7:1 against the pattern here. */
.pw-collab-stage--cutout .pw-collab-stage__veil {
  background:
    linear-gradient(to right,
      rgba(5,5,5,.90) 0,
      rgba(5,5,5,.72) 30%,
      rgba(5,5,5,.30) 58%,
      rgba(5,5,5,.10) 78%,
      rgba(5,5,5,.14) 100%),
    linear-gradient(to bottom,
      rgba(5,5,5,.62) 0,
      rgba(5,5,5,.10) 20%,
      rgba(5,5,5,.06) 52%,
      rgba(5,5,5,.42) 86%,
      rgba(5,5,5,.62) 100%),
    radial-gradient(130% 92% at 50% 45%, transparent 42%, rgba(5,5,5,.46) 100%);
}

/* Below the breakpoint where the copy stops being a left column and becomes
   the full width, a left-weighted scrim would darken one edge for no reason.
   Flatten it. */
@media (max-width: 860px) {
  .pw-collab-stage__veil {
    background:
      linear-gradient(to bottom,
        rgba(5,5,5,.92) 0,
        rgba(5,5,5,.72) 18%,
        rgba(5,5,5,.70) 55%,
        rgba(5,5,5,.88) 85%,
        rgba(5,5,5,.96) 100%),
      radial-gradient(140% 90% at 50% 45%, transparent 20%, rgba(5,5,5,.55) 100%);
  }
}

@media (max-width: 860px) {
  .pw-collab-stage--cutout .pw-collab-stage__veil {
    background:
      linear-gradient(to bottom,
        rgba(5,5,5,.86) 0,
        rgba(5,5,5,.44) 16%,
        rgba(5,5,5,.34) 52%,
        rgba(5,5,5,.70) 84%,
        rgba(5,5,5,.86) 100%),
      radial-gradient(140% 90% at 50% 45%, transparent 30%, rgba(5,5,5,.44) 100%);
  }
}

/* The page itself. .pw-on-stage already pads for the chrome; this only has to
   stop the body painting its own black over the stage behind it. */
body.pw-collab { background: transparent; }

.pw-collab__main {
  position: relative;
  z-index: 1;
  max-width: 68rem;
  margin: 0 auto;
  padding: clamp(3rem, 10vh, 7rem) clamp(1rem, 4vw, 3rem) clamp(3rem, 8vw, 6rem);
}

.pw-collab__head { max-width: 46rem; }

/* On a cut-out stage the figure IS the hero, and the gallery scrolling over
   its legs at rest reads as a layout accident rather than as depth. Give the
   header the first screen so the whole artwork - lantern included - is what
   you land on, and let the work below start at the fold.

   min-height, not height: if the blurb ever runs long the header still grows
   rather than spilling over the section under it. The subtraction is the two
   fixed chrome bands, which .pw-on-stage has already padded for. */
.pw-collab--cutout .pw-collab__head {
  min-height: calc(100vh - var(--pw-chrome-h) - clamp(3rem, 9vw, 6rem));
  min-height: calc(100dvh - var(--pw-chrome-h) - clamp(3rem, 9vw, 6rem));
  display: flex;
  flex-direction: column;
  justify-content: center;
}

/* --pw-brass-bright, not --pw-brass. The accent clears contrast against the
   page's flat black, but this line sits on a photograph, and measured against
   the actual rendered backdrop it came out at 4.33:1 - just under what 11px
   text needs. The brighter step of the same hue clears it with room to spare
   and still reads as the accent. */
.pw-collab__eyebrow {
  margin: 0 0 0.8rem;
  font-family: var(--pw-mono);
  font-size: 0.66rem;
  letter-spacing: 0.24em;
  text-transform: uppercase;
  color: var(--pw-brass-bright);
  text-shadow: 0 1px 10px rgba(0, 0, 0, 0.7);
}

.pw-collab__name {
  margin: 0 0 0.6rem;
  font-family: var(--pw-display);
  font-weight: 900;
  font-size: clamp(2.6rem, 9vw, 5.5rem);
  line-height: 0.98;
  letter-spacing: 0.01em;
  color: var(--pw-bone);
  text-shadow: 0 0 60px rgba(0,0,0,.75);
}

.pw-collab__tagline {
  margin: 0 0 1rem;
  font-family: var(--pw-body);
  font-weight: 700;
  font-size: clamp(1.05rem, 2vw, 1.4rem);
  line-height: 1.3;
  color: var(--pw-bone);
}

/* Not --pw-bone-dim, which is the right grey on a flat panel and the wrong
   one on a photograph: it measured 2.6:1 here. This is the full bone at 88%,
   which keeps the copy secondary to the headline while clearing 4.5:1. */
.pw-collab__blurb {
  margin: 0 0 1.8rem;
  max-width: 54ch;
  font-family: var(--pw-body);
  line-height: 1.65;
  color: rgba(242, 237, 225, 0.88);
  text-shadow: 0 1px 12px rgba(0, 0, 0, 0.55);
}

.pw-collab__tagline { text-shadow: 0 1px 14px rgba(0, 0, 0, 0.6); }

/* A logo standing in for the heading. Still an <h1> with the name as alt
   text - the artwork replaces the LOOK of the heading, never the heading
   itself, so the page keeps one real title that says what it is.

   The drop-shadow filter rather than a box shadow: it follows the shape's
   own alpha, so a logo on transparency gets a shadow around the letters
   instead of around a rectangle they happen to sit in. */
.pw-collab__name--logo {
  margin: 0 0 0.9rem;
  line-height: 0;
  text-shadow: none;
}

.pw-collab__logo,
.pw-collab__logo svg {
  display: block;
  width: min(100%, var(--pw-logo-w, 26rem));
  height: auto;
}

/* On the wrapper rather than the svg, so it is applied once to the whole
   mark instead of per path. */
.pw-collab__logo {
  color: var(--pw-bone);
  filter: drop-shadow(0 2px 24px rgba(0, 0, 0, 0.75));
}

/* The inlined file carries fill="currentColor", so the mark takes the colour
   above and could be re-pointed at the page accent with one line. */
.pw-collab__logo svg { fill: currentColor; }

.pw-collab__acts {
  display: flex;
  flex-wrap: wrap;
  gap: 0.7rem;
  align-items: center;
}

.pw-collab__shop {
  display: inline-block;
  padding: 0.75rem 1.4rem;
  font-family: var(--pw-mono);
  font-size: 0.72rem;
  letter-spacing: 0.16em;
  text-transform: uppercase;
  text-decoration: none;
  color: var(--pw-void);
  background: var(--pw-brass);
  border: 1px solid var(--pw-brass);
  transition: background 0.15s ease, color 0.15s ease;
}

.pw-collab__shop:hover,
.pw-collab__shop:focus-visible {
  background: var(--pw-brass-bright);
  border-color: var(--pw-brass-bright);
}

/* Not a link, and styled so nobody tries to click it twice.

   --pw-bone-dim, not --pw-bone-faint. A disabled control is exempt from the
   contrast minimum, but people still have to READ the word "soon", and faint
   measured 2.28:1 against the backdrop once both stage veils were opened up
   to let the artwork through. Dim comes in over 4.5:1 and is still visibly
   quieter than a live link, which is the whole job of this state.

   The lift was first written as a separate `.pw-collab--cutout
   .pw-collab__shop.is-soon` rule further up this file. That worked only
   because the extra class gave it a higher specificity; the moment it was
   unscoped to cover this stage too it tied with THIS rule, lost on source
   order, and silently did nothing. Changed here instead - one rule, one
   colour, nothing to lose a tie with. */
.pw-collab__shop.is-soon {
  background: transparent;
  color: var(--pw-bone-dim);
  border-color: var(--pw-rule);
  cursor: default;
}

/* THE ACCENT IS THE REST STATE, not the hover one. This used to sit muted and
   only light up once you found it, which is backwards for the one control on
   the page nobody expects: the page makes no sound until somebody asks, so
   the ask has to be visible before the pointer is anywhere near it.

   Hover now goes the other way - brighter and warmer, with the border filling
   in - so there is still somewhere to travel. */
.pw-collab__sound {
  appearance: none;
  padding: 0.75rem 1.2rem;
  background: transparent;
  border: 1px solid var(--pw-brass-dim);
  color: var(--pw-brass);
  font-family: var(--pw-mono);
  font-size: 0.72rem;
  letter-spacing: 0.16em;
  text-transform: uppercase;
  cursor: pointer;
  transition: color 0.15s ease, border-color 0.15s ease, background 0.15s ease;

  /* One bounce every six seconds, not a continuous one. A control that never
     stops moving is noise you learn to ignore inside ten seconds; a beat with
     a long flat pause between reads as something waiting to be pressed. The
     whole movement happens in the first 14% of the cycle and the remaining
     86% is deliberately still.

     The 1.4s delay lets the page arrive first. */
  animation: pw-collab-sound-bounce 6s ease-in-out 1.4s infinite;
}

@keyframes pw-collab-sound-bounce {
  0%              { transform: translateY(0)      scale(1); }
  4%              { transform: translateY(-6px)   scale(1.03); }
  8%              { transform: translateY(0)      scale(0.985); }
  11%             { transform: translateY(-2.5px) scale(1.008); }
  14%, 100%       { transform: translateY(0)      scale(1); }
}

.pw-collab__sound:hover:not(:disabled),
.pw-collab__sound:focus-visible {
  color: var(--pw-brass-bright);
  border-color: var(--pw-brass);
  background: rgba(242, 237, 225, 0.045);
}

/* Stop nagging once it has been answered - or once the pointer is on it.
   The bounce is an invitation, and an invitation that keeps repeating after
   you have accepted it is just a tic. */
.pw-collab__sound[data-state="on"],
.pw-collab__sound:hover,
.pw-collab__sound:focus-visible,
.pw-collab__sound:disabled { animation: none; }

/* On means on: the state has to be visible at a glance, and the word alone is
   a small target for a quick look. */
.pw-collab__sound[data-state="on"] {
  color: var(--pw-brass);
  border-color: var(--pw-brass);
}
.pw-collab__sound[data-state="on"]::before {
  content: "";
  display: inline-block;
  width: 6px; height: 6px;
  margin-right: 0.6rem;
  border-radius: 50%;
  background: currentColor;
  vertical-align: middle;
  animation: pw-collab-pulse 2.4s ease-in-out infinite;
}
.pw-collab__sound:disabled { opacity: 0.45; cursor: not-allowed; }

@keyframes pw-collab-pulse { 50% { opacity: 0.25; } }

/* ---- the gallery ---- */

.pw-collab__work { margin-top: clamp(3rem, 9vw, 6rem); }

.pw-collab__h2 {
  margin: 0 0 1.4rem;
  font-family: var(--pw-mono);
  font-size: 0.68rem;
  letter-spacing: 0.22em;
  text-transform: uppercase;
  font-weight: 400;
  color: var(--pw-bone-faint);
}

.pw-collab__grid {
  margin: 0;
  padding: 0;
  list-style: none;
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(min(15rem, 100%), 1fr));
  gap: clamp(0.8rem, 2vw, 1.4rem);
}

.pw-collab__card { margin: 0; }

/* A square well so a tall product and a wide one still line up as a row, with
   a panel behind it because the stage under this page is busy and a bare
   photograph on top of foliage reads as a mistake. */
.pw-collab__frame {
  position: relative;
  aspect-ratio: 1 / 1;
  display: flex;
  align-items: center;
  justify-content: center;
  overflow: hidden;
  background: rgba(6, 7, 5, 0.72);
  -webkit-backdrop-filter: blur(6px);
  backdrop-filter: blur(6px);
  border: 1px solid var(--pw-rule);
  transition: border-color 0.18s ease, transform 0.18s ease;
}

.pw-collab__card:hover .pw-collab__frame {
  border-color: var(--pw-brass-dim);
  transform: translateY(-3px);
}

.pw-collab__img {
  width: 100%;
  height: 100%;
  object-fit: cover;
  display: block;
}

.pw-collab__frame.is-empty {
  border-style: dashed;
  background: rgba(6, 7, 5, 0.5);
}

.pw-collab__pending {
  font-family: var(--pw-mono);
  font-size: 0.6rem;
  letter-spacing: 0.16em;
  text-transform: uppercase;
  color: var(--pw-bone-faint);
}

.pw-collab__itemname {
  margin: 0.7rem 0 0;
  font-family: var(--pw-display);
  font-size: 1.05rem;
  letter-spacing: 0.03em;
  color: var(--pw-bone);
}

.pw-collab__itemblurb {
  margin: 0.2rem 0 0;
  font-family: var(--pw-body);
  font-size: 0.82rem;
  line-height: 1.5;
  color: var(--pw-bone-dim);
}

.pw-collab__credit {
  margin: clamp(2.5rem, 6vw, 4rem) 0 0;
  font-family: var(--pw-mono);
  font-size: 0.62rem;
  letter-spacing: 0.1em;
  color: var(--pw-bone-faint);
}

/* Motion is the whole point of this page, so reduced motion gets the calm
   version rather than a broken one: the art holds still, the smoke stops
   drifting, and the sound button still works because that was always a
   deliberate choice rather than something that moved on its own. */
@media (prefers-reduced-motion: reduce) {
  .pw-collab-stage__inner,
  .pw-collab-stage__pattern,
  .pw-collab-stage__haze { will-change: auto; transition: none; }
  /* The glow stays LIT rather than being removed - it is part of the picture,
     and a page that loses its lantern under reduced motion has lost content,
     not motion. Only the beating stops. */
  .pw-collab-stage__glow--pulse,
  .pw-collab-stage__glow--flicker { animation: none; opacity: 1; }
  /* The button keeps its accent colour, which is what makes it findable.
     Only the movement goes. */
  .pw-collab__sound { animation: none; }
  .pw-collab-stage__video,
  .pw-collab-stage__figvid { transition: none; }
  .pw-collab__sound[data-state="on"]::before { animation: none; }
  .pw-collab__card:hover .pw-collab__frame { transform: none; }
}

/* ------------------------------------------------- index roster headings */

/* The index groups its roster by category as of the re-org. Everything else
   about that list is styled by the Vite bundle, but a build artifact is the
   wrong place to add a rule — the next npm run build deletes it and changes
   the filename it was referenced by. This one heading lives here instead. */

.pw-roster-head {
  margin: clamp(1.6rem, 4vw, 2.4rem) 0 0.2rem;
  font-family: var(--pw-mono);
  font-size: 0.66rem;
  letter-spacing: 0.22em;
  text-transform: uppercase;
  font-weight: 400;
}

.pw-roster-head a {
  color: var(--pw-bone-faint);
  text-decoration: none;
  border-bottom: 1px solid transparent;
}

.pw-roster-head a:hover,
.pw-roster-head a:focus-visible {
  color: var(--pw-brass);
  border-bottom-color: var(--pw-brass-dim);
}

/* .roster carries a top margin of its own, meant for when it followed the
   lede directly. Under a heading that margin reads as a gap between the
   heading and the list it names, so it is closed here rather than changed
   there — .roster lives in the Vite bundle, which is not ours to edit. */
.pw-roster-head + .roster { margin-top: 0.45rem; }

/* ------------------------------------------------------- category pages */

/* /collaborations/, /projects/ and /giveaways/. Deliberately plain: they are
   signposts, and a signpost that competes with what it points at is doing the
   wrong job. The roster borrows the index's shape without borrowing its
   stylesheet — /assets/index-*.css is a Vite build artifact with a hashed
   name, and anything written there dies at the next build. */

.pw-cat {
  max-width: 62rem;
  margin: 0 auto;
  padding: clamp(2.5rem, 7vw, 5rem) clamp(1rem, 4vw, 3rem) 0;
}

.pw-cat__eyebrow {
  margin: 0 0 0.7rem;
  font-family: var(--pw-mono);
  font-size: 0.66rem;
  letter-spacing: 0.22em;
  text-transform: uppercase;
  color: var(--pw-brass);
}

.pw-cat__title {
  margin: 0 0 0.8rem;
  font-family: var(--pw-display);
  font-weight: 900;
  font-size: clamp(2.2rem, 6vw, 3.6rem);
  line-height: 1.02;
  letter-spacing: 0.02em;
  color: var(--pw-bone);
}

.pw-cat__lede {
  margin: 0 0 clamp(1.8rem, 4vw, 2.8rem);
  max-width: 54ch;
  font-family: var(--pw-body);
  color: var(--pw-bone-dim);
  line-height: 1.6;
}

.pw-cat__list {
  margin: 0;
  padding: 0;
  list-style: none;
  border-top: 1px solid var(--pw-rule);
}

.pw-cat__row { border-bottom: 1px solid var(--pw-rule); }

.pw-cat__link {
  display: grid;
  grid-template-columns: 3ch minmax(0, 1fr) auto;
  align-items: baseline;
  gap: clamp(0.8rem, 2.5vw, 1.6rem);
  padding: clamp(1rem, 2.5vw, 1.5rem) 0.2rem;
  text-decoration: none;
  color: inherit;

  --pw-link:        var(--pw-brass);
  --pw-link-bright: var(--pw-brass-bright);
  --pw-link-dim:    var(--pw-brass-dim);
}

.pw-cat__link:hover,
.pw-cat__link:focus-visible { background: rgba(242, 237, 225, 0.035); }

.pw-cat__index {
  font-family: var(--pw-mono);
  font-size: 0.7rem;
  color: var(--pw-bone-faint);
}

.pw-cat__name {
  font-family: var(--pw-display);
  font-size: clamp(1.15rem, 2.6vw, 1.6rem);
  letter-spacing: 0.03em;
  color: var(--pw-bone);
}

.pw-cat__link:hover .pw-cat__name,
.pw-cat__link:focus-visible .pw-cat__name { color: var(--pw-link-bright); }

/* grid-row: 2 is NOT optional, and leaving it off is what put this line
   through the middle of the name on a phone.

   With a column but no row, this is auto-placed - and auto-placement walks a
   cursor that .pw-cat__go's explicit `grid-row: 1` in the narrow query sends
   back to the first row. The blurb then lands in the same cell as the name
   and the two print on top of each other. Stating the row costs nothing and
   removes the whole class of problem; the index roster has always done it,
   which is why the index never showed this. */
.pw-cat__blurb {
  grid-column: 2;
  grid-row: 2;
  font-family: var(--pw-body);
  font-size: 0.88rem;
  line-height: 1.5;
  color: var(--pw-bone-dim);
}

/* An arrow rather than a word, and turned outward on the one entry that
   leaves the site — the only visual difference between a link that stays
   here and one that does not. */
.pw-cat__go {
  font-family: var(--pw-mono);
  font-size: 0.8rem;
  color: var(--pw-bone-faint);
  justify-self: end;
}

.pw-cat__link:hover .pw-cat__go,
.pw-cat__link:focus-visible .pw-cat__go { color: var(--pw-link); }

@media (max-width: 560px) {
  .pw-cat__link { grid-template-columns: 3ch minmax(0, 1fr); }
  .pw-cat__go { grid-row: 1; grid-column: 2; justify-self: end; }
  /* Gone entirely on a phone, not shrunk. There is one line of room per row
     at this width and the name has already claimed it; a blurb underneath
     doubles the height of every row to say something the name mostly says
     already. The index roster hides its own for the same reason above 40rem.
     Nothing is lost - the destination page opens with the same sentence. */
  .pw-cat__blurb { display: none; }
}

@media (prefers-reduced-motion: reduce) {
  .pw-menu,
  .pw-strip__caret { transition: none; }
}

/* Pages opting in get pushed clear of BOTH fixed bands. */
.pw-on-stage { padding-top: var(--pw-chrome-h); }

/* ---------------------------------------------------------------- footer */

.pw-footer {
  margin-top: clamp(3rem, 8vw, 6rem);
  padding: clamp(1.25rem, 3vw, 2rem) clamp(1rem, 4vw, 3rem);
  border-top: 1px solid var(--pw-rule);
  background: rgba(5, 5, 5, 0.72);
  -webkit-backdrop-filter: blur(10px);
  backdrop-filter: blur(10px);
  font-family: var(--pw-mono);
  font-size: 0.68rem;
  letter-spacing: 0.1em;
  color: var(--pw-bone-faint);
  display: flex;
  flex-wrap: wrap;
  gap: 0.75rem 1.5rem;
  align-items: baseline;
  justify-content: space-between;
}

.pw-footer a { color: var(--pw-bone-dim); text-decoration: none; }
.pw-footer a:hover, .pw-footer a:focus-visible { color: var(--pw-brass); }

/* -------------------------------------------------------- motion opt-in */

/* Rendered only where device orientation needs a prompt (iOS); stage.js
   removes it everywhere else. */
.pw-motion {
  position: fixed;
  left: clamp(0.8rem, 3vw, 1.6rem);
  bottom: clamp(0.8rem, 3vw, 1.6rem);
  z-index: 61;
  font-family: var(--pw-mono);
  font-size: 0.62rem;
  letter-spacing: 0.16em;
  text-transform: uppercase;
  padding: 0.45rem 0.7rem;
  border: 2px solid var(--pw-brass);
  background: rgba(5, 5, 5, 0.72);
  color: var(--pw-brass);
  cursor: pointer;
}

.pw-motion:hover:not(:disabled),
.pw-motion:focus-visible { background: var(--pw-brass); color: var(--pw-void); }
.pw-motion:disabled { opacity: 0.55; }

:focus-visible { outline: 2px solid var(--pw-brass-bright); outline-offset: 2px; }

/* RESTORED 2026-08-27, twice. Dropped once in the re-org, and then a second
   time by a stage.css built on top of a copy taken BEFORE that first restore -
   which is why the Horimukade work silently un-restored the index's Dust
   button. If you are editing this file from a staged copy, re-stage it first;
   the version in your hand may be older than the one on disk. */
/* ========================================================== the sound button

   One button, shared by any page that includes an ambience script. Inert
   on a page that does not — it is `hidden` in the markup until the script
   offers it, so a visitor with no JavaScript never sees it at all.

   Lives here rather than in a file of its own because head.php already
   carries this stylesheet with an mtime cache-buster on every page, and a
   second request for forty lines is a second request. */

/* ---------------------------------------------------------- the button */

/* Bottom RIGHT, deliberately: .pw-motion (and the index's own React tilt
   button) are bottom left, and two controls stacked in one corner read as
   a toolbar the visitor is expected to work through. */
.pw-sound {
  position: fixed;
  right: clamp(0.8rem, 3vw, 1.6rem);
  bottom: clamp(0.8rem, 3vw, 1.6rem);
  z-index: 61;
  display: flex;
  align-items: center;
  gap: 0.5rem;
  font-family: var(--pw-mono);
  font-size: 0.6rem;
  letter-spacing: 0.16em;
  text-transform: uppercase;
  padding: 0.4rem 0.7rem;
  border: 1px solid var(--pw-rule);
  background: rgba(5, 5, 5, 0.74);
  -webkit-backdrop-filter: blur(8px);
  backdrop-filter: blur(8px);
  color: var(--pw-bone-dim);
  cursor: pointer;
  transition: color 160ms ease, border-color 160ms ease;
}

.pw-sound:hover,
.pw-sound:focus-visible { color: var(--pw-brass-bright); border-color: var(--pw-brass-dim); }
.pw-sound[aria-pressed="true"] { color: var(--pw-brass-bright); border-color: var(--pw-brass); }

/* Three bars. Flat while off, moving while on — the state is legible from
   across the room without reading the word, which is the point of having
   a glyph at all. */
.pw-sound__bars {
  display: flex;
  align-items: flex-end;
  gap: 2px;
  height: 0.62rem;
}
.pw-sound__bars i {
  display: block;
  width: 2px;
  background: currentColor;
  transition: height 200ms ease;
}

/* Uneven at rest, on purpose. Three bars of equal height read as an
   ellipsis — "Dust…", as though the button were going to open something
   — where a stepped set reads as a meter that is simply not moving
   yet. */
.pw-sound__bars i:nth-child(1) { height: 3px; }
.pw-sound__bars i:nth-child(2) { height: 7px; }
.pw-sound__bars i:nth-child(3) { height: 4px; }
.pw-sound[aria-pressed="true"] .pw-sound__bars i {
  animation: pw-bars 1.15s ease-in-out infinite;
}
.pw-sound[aria-pressed="true"] .pw-sound__bars i:nth-child(2) { animation-delay: -0.38s; }
.pw-sound[aria-pressed="true"] .pw-sound__bars i:nth-child(3) { animation-delay: -0.72s; }

@keyframes pw-bars {
  0%, 100% { height: 2px; }
  50%      { height: 0.62rem; }
}

/* Reduced motion: the glyph holds still and colour alone carries the
   button's state. There is nothing else here to still — the sound has no
   visual side. An earlier version drove a firelight layer off the audio;
   it was removed rather than dialled down, along with .pw-firelight and
   its --pw-fire custom property. */
@media (prefers-reduced-motion: reduce) {
  .pw-sound[aria-pressed="true"] .pw-sound__bars i { animation: none; height: 0.4rem; }
  .pw-sound { transition: none; }
}


