/* ==========================================================================
   KiroVault design system — v2, violet

   Colour discipline: the page is near-black and silver. Violet behaves like
   LIGHT — glow, rim, focus, state — never large flat fill. That restraint is
   the whole difference between this and a generic gamer/crypto page.
   Gold is retired to one job: premium and limited releases.
   ========================================================================== */
:root{
  /* --------------------------------------------------------------- shape
     Card art used FOUR different ratios: 16/10 on the base plate and every
     wallpaper tile, 16/9 on the hub's category cards, 4/3 on collection
     cards, 5/4 on the hero (16/9 once it stacked on a phone). Moving hub ->
     category -> collection, the art changed shape at every step, which reads
     as three designs rather than one site.

     Two ratios now, and they mean different things:
       --ar-card  everything you browse - wallpaper tiles, category cards,
                  collection cards. One rhythm down the whole funnel.
       --ar-hero  the single large feature block at the top of a page, which
                  is a different job and is allowed to look like one.
     Put a new ratio here or use one of these; do not write a bare fraction
     into a component again. */
  --ar-card:16/10; --ar-hero:16/9;
  /* --ar-tile  a category or collection card: the picture IS the card, name
                on a scrim over it. Portrait so two fit across a phone at a
                size where the art still reads. */
  --ar-tile:4/5;

  /* The darks carry a violet cast rather than being neutral grey. On a page
     this dark the difference is barely nameable and completely visible: the
     black stops reading as "unstyled" and starts reading as night. */
  --void:#050409; --base:#08070E; --panel:#0D0B16; --raise:#151223; --edge:#241D36;
  /* Violet is light, not paint (see CLAUDE.md). Pushed a little brighter and
     a little bluer so it holds up as a glow against the warmer darks. */
  --iris:#AE62FA; --iris-lt:#D4B4FF; --iris-dp:#6D28D9;
  --iris-glow:rgba(174,98,250,.45); --iris-dim:rgba(174,98,250,.13);
  /* Text is never pure grey either — it is very slightly violet, so type and
     background belong to the same world. */
  --white:#FFFFFF; --silver:#EFEBF7; --steel:#AFA8C4; --slate:#817B96; --faint:#453E5C;
  /* --slate was #77718E: 4.33:1 on --base and 3.97 on --raise, under the 4.5
     small text needs, and it is the colour of every caption, meta line and
     footer link. #817B96 is the first step up its own hue that clears 4.5 on
     all four surfaces (5.07 void, 4.97 base, 4.84 panel, 4.56 raise). */
  --gold:#E3BC63;
  /* Two supporting accents. Used for state and edges only, never as fills —
     they exist so the site is not monochrome violet in every single moment. */
  --cyan:#5EE9F0; --rose:#FF7AA8;
  --line:rgba(174,98,250,.16); --line-soft:rgba(239,235,247,.075);
  --font:"Archivo",-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;
  --pad:clamp(20px,5vw,80px); --maxw:1280px; --ease:cubic-bezier(.16,1,.3,1);
}
*{box-sizing:border-box}
html{scroll-behavior:smooth;-webkit-text-size-adjust:100%}
body{margin:0;background:var(--void);color:var(--silver);font-family:var(--font);
  font-size:16.5px;line-height:1.65;font-variation-settings:"wdth" 100,"wght" 400;
  -webkit-font-smoothing:antialiased;overflow-x:hidden;display:flex;flex-direction:column;min-height:100vh}
main{flex:1}
p{margin:0} a{color:inherit} ul,ol{margin:0;padding:0;list-style:none}
img,svg,video{display:block;max-width:100%}
button{font:inherit} [hidden]{display:none!important}
:focus-visible{outline:2px solid var(--iris);outline-offset:3px;border-radius:2px}
.wrap{max-width:var(--maxw);margin:0 auto;padding:0 var(--pad);width:100%}
.narrow{max-width:820px}
.sr{position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0)}
/* The shared gradient <defs> and the grain layer must not take up space.
   Without this the defs SVG renders at its default 300x150 and pushes the
   entire page down. */
.defs{position:absolute;width:0;height:0;overflow:hidden;pointer-events:none}
.grain{position:fixed;inset:0;width:100%;height:100%;pointer-events:none;z-index:3;
  opacity:.035;mix-blend-mode:overlay}
/* Violet light that lags behind the cursor. JS moves it; desktop only. */
.pointer-aura{position:fixed;left:0;top:0;width:600px;height:600px;pointer-events:none;
  z-index:0;border-radius:50%;opacity:.55;filter:blur(90px);will-change:transform;
  background:radial-gradient(circle,rgba(168,85,247,.2),transparent 62%)}
/* Ambient motes. z-index 2 puts them over section backgrounds (which are
   opaque and would otherwise swallow them in bands) but under .grain and the
   nav. screen blend on a near-black page means they read as light rather than
   as dots printed on the text — which is the only way this stays on-brand. */
.ambient{position:fixed;inset:0;z-index:2;pointer-events:none;
  mix-blend-mode:screen;opacity:.5}
@media(prefers-reduced-motion:reduce){.ambient{display:none}}

/* typography — one superfamily, using Archivo's width axis for hierarchy */
h1,h2,h3,h4{margin:0;line-height:1.02;letter-spacing:-.02em;font-variation-settings:"wdth" 112,"wght" 800}
.d1{font-size:clamp(44px,8.4vw,116px);font-variation-settings:"wdth" 118,"wght" 900;
  letter-spacing:-.035em;line-height:.92;text-transform:uppercase}
.d2{font-size:clamp(30px,5vw,64px);font-variation-settings:"wdth" 114,"wght" 850;
  letter-spacing:-.028em;line-height:1;text-transform:uppercase}
.d3{font-size:clamp(22px,2.7vw,32px);font-variation-settings:"wdth" 108,"wght" 750;letter-spacing:-.018em}
.label{font-size:11px;letter-spacing:.34em;text-transform:uppercase;
  font-variation-settings:"wdth" 100,"wght" 500;color:var(--iris)}
.label-mute{color:var(--slate)}
.lede{color:var(--steel);font-size:clamp(15.5px,1.7vw,18px);max-width:58ch;line-height:1.7}
.jp{font-size:12.5px;letter-spacing:.2em;color:var(--iris);opacity:.8}
.tint{color:var(--iris)} .dim{color:var(--slate)} .wht{color:var(--white)}

/* nav */
.nav{position:sticky;top:0;z-index:60;transition:background .35s,border-color .35s}
.nav.stuck{background:rgba(5,5,8,.86);backdrop-filter:blur(20px) saturate(1.3);border-bottom:1px solid var(--line-soft)}
.nav-in{display:flex;align-items:center;justify-content:space-between;height:74px;gap:20px}
.nav-logo{display:flex;align-items:center;gap:12px;text-decoration:none;flex-shrink:0}
.nav-logo .mk{width:30px;height:30px;overflow:visible}
/* The mark is alive: the ring counter-turns with scroll (JS sets --spin), the
   star breathes on hover. Both are transforms on a 5-node SVG, so it costs
   nothing to leave on every page. */
.mk-ring{transform-box:fill-box;transform-origin:center;
  transform:rotate(calc(var(--spin,0) * 1deg));transition:transform .5s var(--ease)}
.mk-star{transform-box:fill-box;transform-origin:center;
  transition:transform .55s var(--ease),opacity .55s}
.mk-crown{transition:opacity .45s}
.nav-logo:hover .mk-star{transform:scale(1.3)}
.nav-logo:hover .mk-ring{transform:rotate(calc(var(--spin,0) * 1deg + 180deg))}
@media(prefers-reduced-motion:reduce){
  .mk-ring,.mk-star{transition:none;transform:none}
}
.nav-logo .wm{font-size:16px;letter-spacing:.2em;font-variation-settings:"wdth" 105,"wght" 700;
  color:var(--white);line-height:1;white-space:nowrap}
.nav-logo .wm i{font-style:normal;color:var(--iris)}
.nav-menu{display:flex;align-items:center;gap:30px}
.nav-menu a{font-size:12.5px;letter-spacing:.16em;text-transform:uppercase;color:var(--steel);
  text-decoration:none;position:relative;padding:6px 0;transition:color .22s;
  font-variation-settings:"wdth" 100,"wght" 500;white-space:nowrap}
.nav-menu a::after{content:"";position:absolute;left:0;bottom:0;height:1px;width:0;
  background:var(--iris);box-shadow:0 0 8px var(--iris-glow);transition:width .3s var(--ease)}
.nav-menu a:hover,.nav-menu a[aria-current="page"]{color:var(--white)}
.nav-menu a:hover::after,.nav-menu a[aria-current="page"]::after{width:100%}
.nav-cta{color:var(--iris)!important;transition:background .25s,color .25s,box-shadow .25s}
/* (0,2,0) on purpose: `.nav-menu a` is (0,1,1), so with `.nav-cta` alone its
   padding:6px 0 won and the border sat against the letters - and on a phone
   its grey border-bottom replaced this violet one */
.nav-menu .nav-cta{border:1px solid var(--iris);padding:10px 18px}
.nav-cta::after{display:none!important}
.nav-cta:hover{background:var(--iris);color:var(--void)!important;box-shadow:0 0 24px var(--iris-glow)}
/* The first stop for a keyboard: past the logo and the thirteen-item menu
   straight to the page (WCAG 2.4.1). Off screen until it has focus. main
   takes the focus it hands over, without drawing a ring round the page. */
.skip{position:fixed;left:16px;top:-80px;z-index:1000;padding:12px 18px;
  background:var(--iris);color:var(--void);font-size:12.5px;letter-spacing:.16em;
  text-transform:uppercase;text-decoration:none;font-variation-settings:"wdth" 100,"wght" 600;
  transition:top .2s var(--ease)}
.skip:focus{top:16px}
main:focus{outline:none}
.burger{display:none;background:none;border:0;padding:10px;cursor:pointer}
.burger span{display:block;width:22px;height:1.5px;background:var(--iris);margin:5px 0;transition:transform .3s,opacity .3s}
.nav.open .burger span:nth-child(1){transform:translateY(6.5px) rotate(45deg)}
.nav.open .burger span:nth-child(2){opacity:0}
.nav.open .burger span:nth-child(3){transform:translateY(-6.5px) rotate(-45deg)}
@media(max-width:1040px){
  .burger{display:block}
  .nav{background:rgba(5,5,8,.92);backdrop-filter:blur(20px);border-bottom:1px solid var(--line-soft)}
  .nav-menu{position:absolute;top:74px;left:0;right:0;flex-direction:column;align-items:stretch;gap:0;
    background:var(--base);border-bottom:1px solid var(--line);padding:8px var(--pad) 24px;display:none}
  .nav.open .nav-menu{display:flex}
  .nav-menu a{padding:15px 0;border-bottom:1px solid var(--line-soft);font-size:14px}
  .nav-cta{margin-top:16px;text-align:center}
  .nav-menu .nav-cta{padding:15px 18px}
}

/* buttons */
.btn{display:inline-flex;align-items:center;justify-content:center;gap:10px;font-size:12.5px;
  letter-spacing:.16em;text-transform:uppercase;font-variation-settings:"wdth" 100,"wght" 600;
  padding:16px 32px;text-decoration:none;cursor:pointer;border:1px solid var(--iris);
  background:transparent;color:var(--iris);position:relative;isolation:isolate;overflow:hidden;
  transition:color .3s,box-shadow .3s,transform .3s var(--ease)}
.btn::before{content:"";position:absolute;inset:0;background:var(--iris);
  transform:translateY(101%);transition:transform .38s var(--ease);z-index:-1}
.btn:hover{color:var(--void);box-shadow:0 0 32px var(--iris-glow);transform:translateY(-2px)}
.btn:hover::before{transform:translateY(0)}
.btn:disabled{opacity:.4;cursor:not-allowed;transform:none;box-shadow:none}
.btn-solid{background:var(--iris);color:var(--void);box-shadow:0 0 26px var(--iris-glow)}
.btn-solid::before{background:var(--iris-lt)}
.btn-solid:hover{color:var(--void)}
.btn-ghost{border-color:var(--edge);color:var(--silver)}
.btn-ghost::before{background:var(--raise)}
.btn-ghost:hover{color:var(--white);border-color:var(--iris);box-shadow:none}
.btn-sm{padding:11px 22px;font-size:11px}
.btn-block{display:flex;width:100%}
.btn-gold{border-color:var(--gold);color:var(--gold)}
.btn-gold::before{background:var(--gold)}
.btn-gold:hover{color:var(--void);box-shadow:0 0 26px rgba(227,188,99,.3)}

/* sections */
.sec{padding:clamp(70px,8.5vw,128px) 0;position:relative;overflow:hidden}
.sec-sm{padding:clamp(46px,5.5vw,80px) 0;position:relative;overflow:hidden}
.sec-line{border-top:1px solid var(--line-soft)}
.sec-panel{background:var(--base)}
.head{margin-bottom:clamp(34px,4.2vw,56px)}
.head .label{margin-bottom:15px;display:block}
.head .lede{margin-top:18px}
.aura{position:absolute;pointer-events:none;z-index:0;border-radius:50%;
  background:radial-gradient(circle,var(--iris-glow),transparent 66%);filter:blur(50px);opacity:.45}
.sec>.wrap{position:relative;z-index:1}

/* cards */
.grid{display:grid;gap:1px;background:var(--line-soft);border:1px solid var(--line-soft)}
.grid-3{grid-template-columns:repeat(3,1fr)}
.grid-2{grid-template-columns:repeat(2,1fr)}
.cell{background:var(--panel);padding:clamp(24px,3vw,38px);transition:background .35s;position:relative}
.cell:hover{background:var(--raise)}
.cell .ico{width:25px;height:25px;fill:none;stroke:var(--iris);stroke-width:1.4;
  stroke-linecap:round;stroke-linejoin:round;margin-bottom:20px}
.cell h3{font-size:18px;color:var(--white);margin-bottom:9px;
  font-variation-settings:"wdth" 106,"wght" 700;letter-spacing:-.01em}
.cell p{color:var(--slate);font-size:14px;line-height:1.62}
.cell-link{text-decoration:none;display:block}
.cell-link:hover h3{color:var(--iris-lt)}
.cell-more{font-size:11px;letter-spacing:.2em;text-transform:uppercase;color:var(--iris);margin-top:16px;display:block}

/* ------------------------------------------------------- category plates
   Generated art (build.py -> art_plate). Every stroke inside is currentColor,
   so a plate takes the accent of whatever section it sits in — which is how
   the three tools keep their separate identities from one drawing routine.
   Layers marked data-depth are moved by the pointer in kv.js. */
.plate{position:relative;overflow:hidden;aspect-ratio:var(--ar-card);
  color:var(--tone,var(--iris));border:1px solid var(--line-soft);
  background:radial-gradient(120% 90% at 50% 112%,rgba(168,85,247,.10),transparent 60%),
    linear-gradient(158deg,#0B0B12,#07070B)}
.plate-svg,.plate-img{position:absolute;inset:0;width:100%;height:100%;display:block}
.plate-img{object-fit:cover;opacity:.85}
.plate-glow{position:absolute;inset:0;pointer-events:none;opacity:0;transition:opacity .45s;
  background:radial-gradient(240px circle at var(--px,50%) var(--py,50%),
    currentColor,transparent 62%);mix-blend-mode:screen;filter:opacity(.22)}
.plate::after{content:"";position:absolute;inset:0;pointer-events:none;
  box-shadow:inset 0 0 44px rgba(0,0,0,.5)}
.plate [data-depth]{transition:transform .6s var(--ease)}

/* ------------------------------------------------- every drawing breathes
   Only a handful of scenes carried an explicit an-* class, so the other two
   hundred cards sat there as flat clipart. This animates the layers of every
   plate — drawn or yet to be drawn — by drifting them against each other at
   different rates. Depth separation is what makes a flat drawing read as a
   place rather than a sticker.

   It animates `translate` and `rotate` (the standalone properties), NOT
   `transform`: the pointer-parallax in kv.js writes inline `transform` on
   these same layers, and a CSS animation on that property would win and kill
   the interaction. The standalone properties compose with it instead. */
.plate [data-depth]{animation:plFloat 9s ease-in-out infinite alternate}
.plate [data-depth]:nth-of-type(2){animation-duration:12.5s;animation-delay:-2.4s}
.plate [data-depth]:nth-of-type(3){animation-duration:15.5s;animation-delay:-5.1s}
.plate [data-depth]:nth-of-type(4){animation-duration:10.5s;animation-delay:-3.3s}
.plate [data-depth]:nth-of-type(5){animation-duration:18s;animation-delay:-7.6s}
.plate [data-depth]:nth-of-type(6){animation-duration:13.5s;animation-delay:-1.2s}
@keyframes plFloat{from{translate:0 0;rotate:0deg}to{translate:2.5px -3px;rotate:.35deg}}
/* the whole scene swells very slightly, so even a one-layer plate is alive */
.plate-svg{animation:plBreathe 14s ease-in-out infinite alternate;
  transform-origin:50% 60%}
@keyframes plBreathe{from{scale:1}to{scale:1.035}}
/* on hover the drift speeds up — the card wakes when you reach for it */
.cat:hover .plate [data-depth],.sub:hover .plate [data-depth],
.cell-art:hover .plate [data-depth]{animation-duration:3.4s}
@media(prefers-reduced-motion:reduce){
  .plate [data-depth],.plate-svg{animation:none!important}
}
.cell-art:hover .plate-glow,.room:hover .plate-glow,.plate:hover .plate-glow{opacity:1}

/* a plated cell loses its padding — the art runs to the edges */
.cell-art{padding:0;display:flex;flex-direction:column}
.cell-art .plate{border:0;border-bottom:1px solid var(--line-soft)}
.cell-txt{padding:clamp(20px,2.6vw,32px);display:flex;flex-direction:column;flex:1}
/* links line up across a row even when the copy runs to different lengths */
.cell-txt .cell-more{margin-top:auto;padding-top:16px}
/* the icon and the link follow the section's accent, so a cyan plate is not
   paired with a violet icon */
.cell-art .ico{stroke:var(--tone,var(--iris))}
.cell-art .cell-more{color:var(--tone-lt,var(--iris))}
@media(prefers-reduced-motion:reduce){.plate [data-depth]{transition:none}}
@media(max-width:900px){.grid-3{grid-template-columns:repeat(2,1fr)}}
@media(max-width:600px){.grid-3,.grid-2{grid-template-columns:1fr}}

/* hex traits, echoing the key visual */
.traits{display:flex;flex-direction:column}
.trait{display:grid;grid-template-columns:54px 1fr;gap:18px;align-items:start;
  padding:18px 0;border-bottom:1px solid var(--line-soft)}
.trait-ico{width:46px;height:46px;display:grid;place-items:center;position:relative}
.trait-ico svg{width:20px;height:20px;fill:none;stroke:var(--iris);stroke-width:1.5;position:relative;z-index:1}
.trait-ico::before{content:"";position:absolute;inset:0;border:1px solid var(--line);
  clip-path:polygon(50% 0,100% 25%,100% 75%,50% 100%,0 75%,0 25%);background:var(--iris-dim)}
.trait h3{font-size:13px;letter-spacing:.2em;text-transform:uppercase;color:var(--white);
  margin-bottom:5px;font-variation-settings:"wdth" 100,"wght" 600}
.trait p{color:var(--slate);font-size:13.5px;line-height:1.55}

/* tiers */
.tiers{border-top:1px solid var(--line)}
.tier{display:grid;grid-template-columns:66px 1.5fr 1fr 190px;gap:clamp(14px,2.6vw,36px);
  align-items:start;padding:clamp(26px,3.4vw,42px) 0;border-bottom:1px solid var(--line-soft);
  position:relative;transition:background .35s}
.tier::before{content:"";position:absolute;left:calc(var(--pad)*-1);right:calc(var(--pad)*-1);top:0;bottom:0;
  background:linear-gradient(90deg,transparent,var(--iris-dim),transparent);opacity:0;
  transition:opacity .4s;pointer-events:none}
.tier:hover::before{opacity:1}
.tier>*{position:relative}
.tier-num{font-size:clamp(26px,3.4vw,40px);color:var(--faint);line-height:1;
  font-variation-settings:"wdth" 118,"wght" 900;transition:color .35s}
.tier:hover .tier-num{color:var(--iris)}
.tier-name{font-size:clamp(22px,2.8vw,32px);color:var(--white);text-decoration:none;
  font-variation-settings:"wdth" 112,"wght" 850;letter-spacing:-.02em;text-transform:uppercase}
.tier:hover .tier-name{color:var(--iris-lt)}
.tier-who{color:var(--slate);font-size:11.5px;letter-spacing:.14em;text-transform:uppercase;margin-top:9px}
.tier-desc{color:var(--steel);font-size:14.5px;margin-top:13px;max-width:38ch}
.tick li{position:relative;padding-left:19px;font-size:13.5px;color:var(--steel);margin-bottom:9px;line-height:1.5}
.tick li::before{content:"";position:absolute;left:0;top:8px;width:5px;height:5px;
  background:var(--iris);transform:rotate(45deg);box-shadow:0 0 8px var(--iris-glow)}
.tier-buy{text-align:right}
.price{font-size:clamp(24px,2.8vw,34px);color:var(--white);display:block;line-height:1;
  font-variation-settings:"wdth" 112,"wght" 800}
.price small{font-size:10.5px;letter-spacing:.12em;text-transform:uppercase;color:var(--slate);
  display:block;margin-top:9px;font-variation-settings:"wdth" 100,"wght" 400}
.tier-buy .btn{margin-top:16px}
.badge{display:inline-block;font-size:9.5px;letter-spacing:.2em;text-transform:uppercase;
  color:var(--iris);border:1px solid var(--iris);padding:4px 9px;margin-bottom:11px;
  font-variation-settings:"wdth" 100,"wght" 600}
@media(max-width:1000px){
  .tier{grid-template-columns:44px 1fr;gap:12px 16px}
  .tier-mid,.tier-list,.tier-buy{grid-column:2}
  .tier-buy{text-align:left;margin-top:6px}
}

/* accordion */
details{border-bottom:1px solid var(--line-soft)}
summary{cursor:pointer;list-style:none;padding:22px 44px 22px 0;position:relative;
  font-size:clamp(16px,1.9vw,20px);color:var(--silver);transition:color .25s;
  font-variation-settings:"wdth" 106,"wght" 650}
summary::-webkit-details-marker{display:none}
summary::after{content:"";position:absolute;right:8px;top:50%;width:10px;height:10px;margin-top:-6px;
  border-right:1.5px solid var(--iris);border-bottom:1.5px solid var(--iris);
  transform:rotate(45deg) translateY(-3px);transition:transform .3s}
details[open] summary{color:var(--iris-lt)}
details[open] summary::after{transform:rotate(-135deg) translateY(-3px)}
summary:hover{color:var(--white)}
details .body{padding:0 44px 24px 0;color:var(--steel);font-size:15px;max-width:70ch}

/* forms */
.input{width:100%;padding:15px 17px;font-family:var(--font);font-size:14px;background:var(--panel);
  border:1px solid var(--edge);color:var(--white);transition:border-color .25s,box-shadow .25s}
.input::placeholder{color:var(--faint)}
.input:focus{outline:none;border-color:var(--iris);box-shadow:0 0 0 3px var(--iris-dim)}
.input.err{border-color:#C4506B}
.form-inline{display:flex;gap:10px;max-width:540px;flex-wrap:wrap}
.form-inline .input{flex:1;min-width:220px}
.msg{margin-top:14px;font-size:13.5px}
.msg-ok{color:var(--iris-lt)} .msg-err{color:#D97A90}
.hint{font-size:12px;color:var(--slate);margin-top:13px}
.field{display:block;margin-bottom:18px}
.field span,.field>label{display:block;font-size:10.5px;letter-spacing:.24em;text-transform:uppercase;
  color:var(--iris);margin-bottom:9px}
.opts{display:flex;flex-wrap:wrap;gap:8px}
.opt{flex:1 1 auto;min-width:125px;padding:13px 15px;border:1px solid var(--edge);background:var(--panel);
  color:var(--steel);font-size:13px;cursor:pointer;text-align:center;transition:all .25s;font-family:var(--font)}
.opt:hover{border-color:var(--slate);color:var(--white)}
.opt[aria-pressed="true"]{border-color:var(--iris);color:var(--iris-lt);background:var(--iris-dim);
  box-shadow:0 0 20px var(--iris-dim)}
.panel-box{background:var(--panel);border:1px solid var(--line-soft);padding:clamp(24px,3.4vw,40px)}

/* footer */
.foot{border-top:1px solid var(--line-soft);padding:clamp(46px,5.5vw,70px) 0 38px;background:var(--base);margin-top:auto}
.foot-top{display:grid;grid-template-columns:1.3fr 2.2fr;gap:clamp(28px,4.6vw,60px);
  padding-bottom:38px;border-bottom:1px solid var(--line-soft)}
.foot-brand p{color:var(--slate);font-size:13px;max-width:32ch;margin-top:13px}
.foot-social{display:flex;gap:11px;margin-top:18px}
.foot-social a{width:34px;height:34px;border:1px solid var(--edge);display:grid;place-items:center;
  transition:border-color .25s,background .25s}
.foot-social a:hover{border-color:var(--iris);background:var(--iris-dim)}
.foot-social svg{width:15px;height:15px;stroke:var(--iris);fill:none;stroke-width:1.5}
.foot-nav{display:grid;grid-template-columns:repeat(4,1fr);gap:20px}
.foot-nav h2{font-size:10px;letter-spacing:.26em;text-transform:uppercase;color:var(--iris);
  margin:0 0 15px;font-variation-settings:"wdth" 100,"wght" 600}
.foot-nav a{display:block;color:var(--slate);text-decoration:none;font-size:13px;padding:5px 0;transition:color .22s}
.foot-nav a:hover{color:var(--white)}
.disc{margin-top:28px;color:#4A4A5E;font-size:11px;line-height:1.75;max-width:96ch}
.disc strong{color:var(--slate)}
.copy{margin-top:22px;color:var(--slate);font-size:11px;display:flex;justify-content:space-between;gap:16px;flex-wrap:wrap}
@media(max-width:880px){.foot-top{grid-template-columns:1fr}.foot-nav{grid-template-columns:repeat(2,1fr)}}

/* prose */
.prose h2{font-size:clamp(21px,2.5vw,29px);margin:38px 0 13px;color:var(--white);
  font-variation-settings:"wdth" 108,"wght" 750}
.prose h3{font-size:16px;margin:26px 0 9px;color:var(--iris-lt);font-variation-settings:"wdth" 102,"wght" 650}
.prose p{color:var(--steel);margin-bottom:14px;font-size:15px}
.prose ul{margin:0 0 17px}
.prose li{color:var(--steel);position:relative;padding-left:19px;margin-bottom:8px;font-size:15px}
.prose li::before{content:"";position:absolute;left:0;top:10px;width:4px;height:4px;background:var(--iris);transform:rotate(45deg)}
.prose strong{color:var(--white)} .prose a{color:var(--iris-lt)}
.updated{font-size:12px;color:var(--slate);border-left:2px solid var(--iris);padding-left:13px}
code{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:.88em;color:var(--iris-lt);
  background:var(--iris-dim);padding:2px 6px}
.crumb{font-size:12px;letter-spacing:.1em;text-transform:uppercase;color:var(--slate);margin-bottom:20px}
/* 12px type is a 14px-tall link, which a thumb misses. The padding makes the
   target ~42px and the matching negative margin keeps the line where it was. */
.crumb a{color:var(--iris);text-decoration:none;display:inline-block;
  padding:14px 0;margin:-14px 0}
.crumb span{margin:0 8px;color:var(--faint)}

/* motion — reveal on entry; nothing loops */
.rv{opacity:0;transform:translateY(20px);transition:opacity .85s var(--ease),transform .85s var(--ease)}
.rv.in{opacity:1;transform:none}
.rv-d1{transition-delay:.08s}.rv-d2{transition-delay:.16s}.rv-d3{transition-delay:.24s}

@media(prefers-reduced-motion:reduce){
  *{animation:none!important;transition:none!important}
  html{scroll-behavior:auto}
  .rv{opacity:1;transform:none}
  .parallax,.hero-art{transform:none!important}
}

/* ==========================================================================
   Motion layer
   Scroll- and pointer-driven only. Scene elements expose --p (0 to 1) as
   their scroll progress, which CSS below maps onto transforms.
   ========================================================================== */

/* progress bar */
.scroll-bar{position:fixed;top:0;left:0;right:0;height:2px;z-index:80;transform-origin:0 50%;
  transform:scaleX(0);background:linear-gradient(90deg,var(--iris-dp),var(--iris),var(--iris-lt));
  box-shadow:0 0 12px var(--iris-glow);will-change:transform}

/* word-by-word rise */
.split-w{display:inline-block;overflow:hidden;vertical-align:bottom;
  padding:0 .04em .12em;margin:0 -.04em -.12em}
.split-w>span{display:inline-block;transform:translateY(105%);opacity:0;
  transition:transform .85s var(--ease),opacity .85s var(--ease)}
.split-host.in .split-w>span{transform:translateY(0);opacity:1}

/* staggered children */
[data-stagger]>*{opacity:0;transform:translateY(26px);
  transition:opacity .75s var(--ease),transform .75s var(--ease)}
[data-stagger].in>*{opacity:1;transform:none}
[data-stagger].in>*:nth-child(1){transition-delay:.05s}
[data-stagger].in>*:nth-child(2){transition-delay:.13s}
[data-stagger].in>*:nth-child(3){transition-delay:.21s}
[data-stagger].in>*:nth-child(4){transition-delay:.29s}
[data-stagger].in>*:nth-child(5){transition-delay:.37s}
[data-stagger].in>*:nth-child(6){transition-delay:.45s}

/* grow-in */
[data-grow]{opacity:0;transform:scale(.94);transition:opacity 1s var(--ease),transform 1s var(--ease)}
[data-grow].in{opacity:1;transform:none}

/* The WebGL canvas stands in for the <img> and inherits its classes, so it
   picks up the same size, mask and animations. It must not be display:block
   only — it has to fill exactly what the image filled or the mask slips. */
/* The canvas fills its rig absolutely and is sized entirely from inline
   styles set in kv.js. Deliberately NO class-based sizing here: the canvas
   used to wear the image's classes and inherit `height:auto` /
   `aspect-ratio`, which fought the absolute fill and drew Kiro perfectly into
   a box positioned where nobody could see him. */
canvas.is-3d{display:block}

/* ==========================================================================
   The watcher — Kiro, present on every page
   Bottom-left, small, half-lit. He fades in once you have scrolled past the
   first screen so he never fights the hero, and he is decorative: aria-hidden,
   pointer-events:none, and removed outright on touch or reduced motion.
   ========================================================================== */
.watcher{position:fixed;left:18px;bottom:18px;z-index:40;width:132px;
  pointer-events:none;opacity:0;transform:translateY(22px) scale(.96);
  transition:opacity .9s var(--ease),transform .9s var(--ease)}
.watcher.on{opacity:1;transform:none}
/* The handoff. On a page he is standing on, the corner copy is not a second
   Kiro — it is the same one, further away: kv.js shows it only once the rig
   has left the screen, and takes it away again when you scroll back. The
   "two Kiros on one page" rule (CLAUDE.md) is kept by never showing both. */
.watcher-hand{transition:opacity .6s var(--ease),transform .7s var(--ease)}
/* Lean layer. JS writes --wx/--wy/--wr/--ws/--wt on .watcher and they inherit
   down. He turns AND tips AND swells slightly — a flat picture can carry
   whole-body movement convincingly, which is where his life comes from now
   that the eyelid overlay is gone. */
.watcher-fig{position:relative;display:block;transform-origin:50% 85%;
  transform:translate3d(var(--wx,0px),var(--wy,0px),0)
            rotate(calc(var(--wr,0deg) + var(--wt,0deg)))
            scale(var(--ws,1));
  transition:transform .55s var(--ease)}
/* he snaps his attention rather than easing when he looks at something */
.watcher.look .watcher-fig{transition:transform .28s cubic-bezier(.2,1.6,.4,1)}
.watcher.look{--ws:1.07}
/* a short dip — agreement, or acknowledging you took something */
.watcher.nod .kiro-idle{animation:kiroNod .75s var(--ease)}
@keyframes kiroNod{
  0%{transform:none}
  35%{transform:translateY(7px) rotate(2.5deg) scale(.97)}
  100%{transform:none}}
/* his glow takes the colour of the world you are standing in */
.watcher-glow{background:radial-gradient(circle at 52% 42%,
  color-mix(in srgb,var(--kiro-tone,var(--iris)) 46%,transparent),transparent 62%)}
.watcher.look .watcher-glow{opacity:.85}
.watcher-fig .kiro-idle{position:relative;display:block}
.watcher img{width:100%;height:auto;display:block;border-radius:50%;
  filter:grayscale(.15) contrast(1.08);
  -webkit-mask-image:radial-gradient(circle at 50% 44%,#000 54%,transparent 74%);
  mask-image:radial-gradient(circle at 50% 44%,#000 54%,transparent 74%)}
.watcher-glow{position:absolute;inset:-18% -18% -6%;border-radius:50%;
  background:radial-gradient(circle at 52% 42%,var(--iris-glow),transparent 62%);
  filter:blur(22px);opacity:.55;
  animation:watchPulse 6.5s var(--ease) infinite alternate}
@keyframes watchPulse{from{opacity:.32;transform:scale(.94)}to{opacity:.62;transform:scale(1.04)}}
/* He speaks in his own time now, so the bubble is driven by .on on ITSELF
   rather than by the watcher being visible — otherwise every line he says
   would be stuck on screen permanently. */
/* A bubble, not a bare line: filmed on the home page, his words sat on top
   of whatever section was behind him and read as stray text. */
.watcher-say{position:absolute;left:calc(100% - 6px);bottom:24px;width:210px;
  font-size:11.5px;line-height:1.55;color:var(--silver);
  border:1px solid color-mix(in srgb,var(--kiro-tone,var(--iris)) 42%,transparent);
  border-left:2px solid var(--kiro-tone,var(--iris));
  background:rgba(10,10,16,.9);padding:9px 12px;
  box-shadow:0 14px 34px rgba(0,0,0,.45);
  opacity:0;transform:translateX(-10px);
  transition:opacity .45s var(--ease),transform .55s var(--ease)}
.watcher-say.on{opacity:1;transform:none}
@media(max-width:1280px){.watcher-say{display:none}}
@media(max-width:900px){.watcher{display:none}}
@media(prefers-reduced-motion:reduce){.watcher{display:none}}

/* ==========================================================================
   Topic scenes
   A moving atmosphere per collection: One Piece gets a sea, Fire Force gets
   embers, Tokyo Nights gets rain. Defined once here and referenced by name
   from build.py's topic_theme(), so two thousand collections add no CSS.

   Every scene paints in --tone and --tone2 (the topic's colours) over the
   Vault's violet, which is what keeps them all recognisably the same site.
   All of them are transform/opacity keyframes on three spans — no canvas, no
   JS, nothing per-page.
   ========================================================================== */
.scene{position:absolute;inset:0;overflow:hidden;pointer-events:none;z-index:0}
.scene>span{position:absolute;inset:-20% -10%;display:block}
.scene::after{content:"";position:absolute;inset:0;
  background:radial-gradient(ellipse 80% 60% at 50% 0%,
    color-mix(in srgb,var(--tone) 20%,transparent),transparent 70%)}

/* --- waves: slow horizontal swells ------------------------------------- */
.scene-waves>span{background:
  radial-gradient(120% 42% at 50% 100%,color-mix(in srgb,var(--tone) 26%,transparent),transparent 70%);
  animation:scWave 13s ease-in-out infinite}
.scene-waves>span:nth-child(2){inset:-10% -10% -30%;opacity:.6;animation-duration:19s;
  animation-direction:reverse;
  background:radial-gradient(110% 38% at 40% 100%,color-mix(in srgb,var(--tone2) 30%,transparent),transparent 70%)}
.scene-waves>span:nth-child(3){opacity:.4;animation-duration:26s}
@keyframes scWave{0%{transform:translate3d(-4%,4%,0) scaleY(1)}
  50%{transform:translate3d(4%,-3%,0) scaleY(1.12)}
  100%{transform:translate3d(-4%,4%,0) scaleY(1)}}

/* --- embers: sparks rising -------------------------------------------- */
.scene-embers>span{background-image:
  radial-gradient(2px 2px at 20% 90%,var(--tone),transparent),
  radial-gradient(1.6px 1.6px at 45% 95%,var(--tone2),transparent),
  radial-gradient(2.4px 2.4px at 70% 88%,var(--tone),transparent),
  radial-gradient(1.4px 1.4px at 88% 96%,var(--tone2),transparent);
  background-size:340px 340px;opacity:.75;animation:scRise 17s linear infinite}
.scene-embers>span:nth-child(2){background-size:230px 230px;opacity:.5;
  animation-duration:24s;animation-delay:-6s}
.scene-embers>span:nth-child(3){background-size:460px 460px;opacity:.35;
  animation-duration:31s;animation-delay:-12s}
@keyframes scRise{from{transform:translate3d(0,12%,0)}to{transform:translate3d(-6%,-70%,0)}}

/* --- snow: drifting down ---------------------------------------------- */
.scene-snow>span{background-image:
  radial-gradient(2.2px 2.2px at 15% 10%,var(--tone),transparent),
  radial-gradient(1.6px 1.6px at 55% 25%,var(--tone2),transparent),
  radial-gradient(2px 2px at 80% 5%,var(--tone),transparent);
  background-size:300px 300px;opacity:.6;animation:scFall 22s linear infinite}
.scene-snow>span:nth-child(2){background-size:200px 200px;opacity:.4;animation-duration:31s}
.scene-snow>span:nth-child(3){background-size:420px 420px;opacity:.28;animation-duration:40s}
@keyframes scFall{from{transform:translate3d(-3%,-40%,0)}to{transform:translate3d(3%,70%,0)}}

/* --- rain: streaks ----------------------------------------------------- */
.scene-rain>span{background-image:repeating-linear-gradient(104deg,
  transparent 0 26px,color-mix(in srgb,var(--tone) 34%,transparent) 26px 27.2px);
  opacity:.30;animation:scRain 1.5s linear infinite}
.scene-rain>span:nth-child(2){opacity:.18;animation-duration:2.3s;
  background-image:repeating-linear-gradient(101deg,
    transparent 0 40px,color-mix(in srgb,var(--tone2) 40%,transparent) 40px 41.4px)}
.scene-rain>span:nth-child(3){opacity:.1;animation-duration:3.4s}
@keyframes scRain{from{transform:translate3d(0,-14%,0)}to{transform:translate3d(-5%,14%,0)}}

/* --- stars: a slow turning field --------------------------------------- */
.scene-stars>span{background-image:
  radial-gradient(1.6px 1.6px at 12% 22%,var(--tone),transparent),
  radial-gradient(1.2px 1.2px at 38% 64%,var(--tone2),transparent),
  radial-gradient(2px 2px at 72% 31%,var(--tone),transparent),
  radial-gradient(1.4px 1.4px at 88% 78%,var(--tone2),transparent);
  background-size:280px 280px;opacity:.7;animation:scTwinkle 6s ease-in-out infinite}
.scene-stars>span:nth-child(2){background-size:400px 400px;opacity:.45;
  animation-duration:9s;animation-delay:-3s}
.scene-stars>span:nth-child(3){background-size:560px 560px;opacity:.3;
  animation:scTurn 120s linear infinite}
@keyframes scTwinkle{0%,100%{opacity:.28}50%{opacity:.8}}
@keyframes scTurn{to{transform:rotate(360deg)}}

/* --- speed: light streaking past --------------------------------------- */
.scene-speed>span{background-image:linear-gradient(90deg,
  transparent 0%,color-mix(in srgb,var(--tone) 45%,transparent) 48%,transparent 100%);
  height:2px;top:32%;inset-inline:-40%;bottom:auto;opacity:.6;
  animation:scDash 2.6s var(--ease) infinite}
.scene-speed>span:nth-child(2){top:58%;height:1px;opacity:.4;animation-duration:3.6s;
  animation-delay:-.9s;background-image:linear-gradient(90deg,
    transparent 0%,color-mix(in srgb,var(--tone2) 55%,transparent) 48%,transparent 100%)}
.scene-speed>span:nth-child(3){top:76%;height:1.4px;opacity:.3;animation-duration:4.6s;
  animation-delay:-2s}
@keyframes scDash{from{transform:translateX(-60%)}to{transform:translateX(60%)}}

/* --- bloom: soft breathing colour -------------------------------------- */
.scene-bloom>span{background:radial-gradient(closest-side,
  color-mix(in srgb,var(--tone) 34%,transparent),transparent 70%);
  width:60%;height:60%;inset:auto auto 10% 8%;filter:blur(28px);
  animation:scBloom 15s ease-in-out infinite}
.scene-bloom>span:nth-child(2){inset:8% 6% auto auto;width:52%;height:52%;
  animation-duration:21s;animation-delay:-5s;
  background:radial-gradient(closest-side,
    color-mix(in srgb,var(--tone2) 36%,transparent),transparent 70%)}
.scene-bloom>span:nth-child(3){inset:30% auto auto 40%;width:40%;height:40%;
  animation-duration:27s;animation-delay:-11s;opacity:.7}
@keyframes scBloom{0%,100%{transform:scale(.9) translate(0,0)}
  50%{transform:scale(1.2) translate(6%,-5%)}}

/* --- glyph: expanding rings, for the word collections ------------------ */
.scene-glyph>span{border:1px solid color-mix(in srgb,var(--tone) 40%,transparent);
  border-radius:50%;inset:50% auto auto 50%;width:32vmax;height:32vmax;
  translate:-50% -50%;animation:scPulse 9s var(--ease) infinite}
.scene-glyph>span:nth-child(2){animation-delay:-3s;
  border-color:color-mix(in srgb,var(--tone2) 40%,transparent)}
.scene-glyph>span:nth-child(3){animation-delay:-6s}
@keyframes scPulse{from{transform:scale(.25);opacity:0}
  25%{opacity:.55}to{transform:scale(1.5);opacity:0}}

/* --- drift: the default, soft floating light --------------------------- */
.scene-drift>span{background:radial-gradient(closest-side,
  color-mix(in srgb,var(--tone) 26%,transparent),transparent 70%);
  width:56%;height:56%;inset:auto auto 4% 4%;filter:blur(34px);
  animation:scDrift 24s ease-in-out infinite}
.scene-drift>span:nth-child(2){inset:2% 2% auto auto;width:48%;height:48%;
  animation-duration:33s;animation-direction:reverse;
  background:radial-gradient(closest-side,
    color-mix(in srgb,var(--tone2) 28%,transparent),transparent 70%)}
.scene-drift>span:nth-child(3){inset:34% auto auto 30%;width:34%;height:34%;
  animation-duration:41s;opacity:.6}
@keyframes scDrift{0%,100%{transform:translate(0,0) scale(1)}
  33%{transform:translate(9%,-7%) scale(1.14)}
  66%{transform:translate(-7%,5%) scale(.92)}}

@media(prefers-reduced-motion:reduce){
  .scene>span{animation:none!important}
}

/* --------------------------------------------------------- topic theming
   .topic wraps a whole collection page and carries --tone/--tone2, so the
   theme reaches the grid, the chips and the buttons rather than stopping at
   the header. Violet is still the site's own colour everywhere else — the
   nav, the mark, the cursor — which is what keeps a One Piece page and a
   Fire Force page recognisably the same building. */
.topic .sub-hero>.wrap{position:relative;z-index:1}
/* a stronger wash than the category default: the topic has to be readable in
   the first second, before anyone scrolls to the art */
.topic .sub-hero{background:
  linear-gradient(180deg,color-mix(in srgb,var(--tone) 15%,transparent),transparent 78%),
  radial-gradient(ellipse 60% 70% at 88% 0%,
    color-mix(in srgb,var(--tone2) 20%,transparent),transparent 72%)}
.topic .label{color:var(--tone)}
.topic .crumb a{color:var(--tone)}
.topic .chip:hover{border-color:var(--tone);color:var(--tone)}
.topic .wp-nav a:hover{color:var(--tone)}
.topic .btn-solid{background:var(--tone);border-color:var(--tone);
  box-shadow:0 0 26px color-mix(in srgb,var(--tone) 42%,transparent)}
.topic .btn-solid::before{background:var(--tone2)}
.topic .btn-ghost:hover{border-color:var(--tone);color:var(--tone)}
.topic .void-mark span{background:var(--tone)}
.topic .d1{text-shadow:0 0 70px color-mix(in srgb,var(--tone) 30%,transparent)}

/* ==========================================================================
   Kiro, alive
   He is one flat picture, so the blink is faked: a skin-toned lid sits over
   each eye and scales down over it. The coordinates below are measured off
   the actual artwork (irises located by cropping and enlarging the files),
   which is the only reason it lands — a lid a few percent out reads as a
   smudge on his face rather than an eyelid.

   Two different pictures, two different sets of eyes:
     .eyes-portrait  kiro-portrait.webp   both eyes, head tilted ~27deg
     .eyes-hero      kiro-hero.webp       one eye visible, tilted ~12deg
   ========================================================================== */
.eyes{position:absolute;inset:0;pointer-events:none;z-index:2}
.lid{position:absolute;display:block;
  transform-origin:50% 0;
  transform:rotate(var(--r,0deg)) scaleY(0);
  /* sampled from the art: shadowed upper lid into under-eye skin */
  background:linear-gradient(180deg,#241d20 0%,#453739 72%,#4b3d3f 100%);
  border-radius:50% 50% 46% 46%/72% 72% 28% 28%;
  filter:blur(.7px);
  transition:transform .085s cubic-bezier(.45,0,.55,1)}
.blink .lid{transform:rotate(var(--r,0deg)) scaleY(1)}

.eyes-portrait .lid-a{left:43.1%;top:52.4%;width:13%;height:7%;--r:-27deg}
.eyes-portrait .lid-b{left:15.4%;top:66.1%;width:15%;height:6.4%;--r:-27deg}
.eyes-hero .lid-a{left:57.1%;top:34.0%;width:9%;height:5%;--r:-12deg}

/* ---------------------------------------------------------------- shine
   The lid is the mechanical half of being alive; this is the other half. A
   violet catchlight sits in each iris, brightens as the pointer comes near
   him, and goes out for exactly as long as the lid is down. It is screen-
   blended, so on a dark iris it reads as light in the eye rather than as a
   dot stuck on his face — which also means it survives being a percent or
   two out, where the lid would not. */
   REMOVED, deliberately. It was drawn at ~9% of the image width at 95%
   opacity, screen-blended. A real catchlight is a pinprick — a couple of
   pixels. At that size it stopped being light in the eye and became a glowing
   orb pasted on his face, visible from across the room, and on the portrait
   the second one landed on his cheek. It made him look broken, not alive.

   The rule it taught: an overlay on flat art must be small enough that a
   two-percent placement error is invisible. The lid passes that test — it is
   skin-coloured and eye-shaped. A bright glowing dot does not. Do not
   reintroduce a shine without layered artwork to anchor it to. */
.shine{display:none!important}

/* He breathes. Defined here rather than on the home page because more than
   one page stands him up now, and his lungs should not be page-scoped. */
/* HE LOOKS AROUND. The whole figure turns a few degrees one way, holds,
   turns the other, holds — uneven, the way a person glances about a room —
   on the individual `rotate` property, so it adds to the breath (which owns
   `transform`) and to the pointer turn on his parent, and on every device,
   including the phones that get no shader. The rig's parent carries the
   perspective that makes a Y-turn read as a turn and not a squash. */
@keyframes kiroLook{
  0%,100%{rotate:y 0deg}
  16%,30%{rotate:y -7deg}
  48%,62%{rotate:y 5deg}
  80%{rotate:y -2deg}}
@keyframes kiroBreathe{
  0%{transform:translate3d(0,0,0) scale(1)}
  50%{transform:translate3d(0,-14px,0) scale(1.012)}
  100%{transform:translate3d(0,0,0) scale(1)}}

/* He notices you. --near is written by kv.js from the pointer's distance to
   him: further away he is a picture, close up he lifts and lights. */
[data-rig]{transition:transform 1.1s var(--ease),filter .8s var(--ease)}
[data-rig].near{filter:brightness(calc(1 + var(--near,0) * .1))
  saturate(calc(1 + var(--near,0) * .18))}
/* a single involuntary movement — he looks over, then back */
[data-rig].glance{animation:kiroGlance 1.5s var(--ease)}
@keyframes kiroGlance{
  0%{transform:none}
  22%{transform:translate3d(-6px,-2px,0) rotate(-.7deg)}
  70%{transform:translate3d(-4px,-1px,0) rotate(-.5deg)}
  100%{transform:none}}
/* and one when you actually touch him */
[data-rig].jolt{animation:kiroJolt .7s var(--ease)}
@keyframes kiroJolt{
  0%{transform:none}
  30%{transform:translate3d(5px,-8px,0) scale(1.012)}
  100%{transform:none}}
/* what he says when you prod him. Positioned in the viewport by kv.js, next
   to whichever copy of him you touched. */
.rig-say{position:fixed;z-index:120;pointer-events:none;max-width:min(280px,60vw);
  padding:11px 15px;background:rgba(8,8,12,.9);backdrop-filter:blur(10px);
  border:1px solid var(--line);color:var(--silver);font-size:13.5px;line-height:1.5;
  opacity:0;transform:translateY(8px) scale(.97);
  transition:opacity .4s var(--ease),transform .5s var(--ease);
  box-shadow:0 20px 60px rgba(0,0,0,.6),0 0 40px var(--iris-dim)}
.rig-say::before{content:"";position:absolute;left:-1px;top:12px;bottom:12px;width:2px;
  background:var(--iris);box-shadow:0 0 12px var(--iris-glow)}
.rig-say.on{opacity:1;transform:none}

@media(prefers-reduced-motion:reduce){
  .shine{transition:none}
  [data-rig].glance,[data-rig].jolt{animation:none}
  .rig-say{transition:none}}

/* the whole figure drifts: a slow idle sway on top of everything else, so he
   is never perfectly still even with the pointer off the page */
.kiro-idle{animation:kiroIdle 17s ease-in-out infinite}
@keyframes kiroIdle{
  0%{transform:translate3d(0,0,0) rotate(0deg)}
  28%{transform:translate3d(-5px,-7px,0) rotate(-.5deg)}
  55%{transform:translate3d(4px,-3px,0) rotate(.35deg)}
  80%{transform:translate3d(-2px,5px,0) rotate(-.2deg)}
  100%{transform:translate3d(0,0,0) rotate(0deg)}}
@media(prefers-reduced-motion:reduce){
  .eyes{display:none}
  .kiro-idle{animation:none}
}

/* ==========================================================================
   Click feedback
   Every click leaves a mark: a ring that expands from the exact point and a
   press state on whatever was hit. Both are transform/opacity only.
   ========================================================================== */
.tap{position:fixed;left:0;top:0;width:14px;height:14px;margin:-7px 0 0 -7px;
  border:1px solid var(--iris);border-radius:50%;pointer-events:none;z-index:9997;
  animation:tapRing .62s var(--ease) forwards}
@keyframes tapRing{
  0%{transform:scale(.4);opacity:.9;border-width:2px}
  100%{transform:scale(7);opacity:0;border-width:.5px}}
.tap-2{animation-duration:.9s;animation-delay:.06s;border-color:var(--iris-lt)}

/* anything clickable dips when pressed and springs back */
a,button,.opt,.wp,.cat,.sub,.fg-p,.fg-d,.wp-f,.ad-item,.ad-mini{
  -webkit-tap-highlight-color:transparent}
.is-press{transform:scale(.972)!important;transition:transform .09s ease-out!important}
@media(prefers-reduced-motion:reduce){
  .tap{display:none}
  .is-press{transform:none!important}
}

/* ==========================================================================
   Wallpaper cards
   Global, not page-scoped: the home page, the gallery and the detail pages
   all show these, and a card should not depend on where it lands.
   ========================================================================== */
.wp-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:clamp(12px,1.6vw,22px)}
@media(max-width:1000px){.wp-grid{grid-template-columns:repeat(2,1fr)}}
@media(max-width:600px){.wp-grid{grid-template-columns:1fr}}
.wp{position:relative;display:block;text-decoration:none;--tone:var(--iris)}
.wp-frame{position:relative;display:block;overflow:hidden;aspect-ratio:var(--ar-card);
  background:var(--panel);border:1px solid var(--line-soft)}
/* 50% 15%, not the centre: the site's one crop rule (intake.py crop_to, the
   viewer's region()) takes 15% of any trimmed height off the TOP, because
   losing the feet is always better than losing the face. The cards centred
   instead, so the 444 portrait pictures lost the top 29% of their height in a
   16:10 card - Cowboy Bebop's only wallpaper was a headless torso. Every
   cover-cropped picture below (wall, tile, peek) uses the same 15%, unless
   focus.json gives a close-up its own --fy (its face sits lower). */
.wp-img{width:100%;height:100%;object-fit:cover;object-position:50% var(--fy,15%);display:block;
  transition:transform 1.1s var(--ease)}
/* No will-change here. It was on every image, so a collection page asked the
   compositor for a layer per wallpaper before anything moved — past a phone's
   layer budget everything that DID animate was rasterised soft and dropped
   frames. The browser promotes the one image being hovered by itself. */
@media(hover:hover){.wp:hover .wp-img{transform:scale(1.07)}}
/* ---------------------------------------------------------- 3D cards ----
   Every card on the site leans into the screen under the pointer and the
   picture inside it travels the other way. Opposing directions is the whole
   trick — the same one .hero-echo uses — because matching them just reads as
   the card sliding about.

   The lean is on the CARD and the parallax is on the IMAGE, and they are
   deliberately different properties. .wp-img's transform is already spoken
   for by the hover scale, and the reveal engine writes transform:none onto
   images inline, which would win over anything set here. object-position
   moves the picture within its own frame instead, composes with the scale
   rather than fighting it, and the 1.07 hover scale is what supplies the
   overflow for it to move into.

   Driven from CSS variables so one delegated pointermove in kv.js can serve
   a page of forty cards, or a thousand, without a listener on each. */
.wp,.sub,.cat{transition:transform .4s var(--ease)}
.wp.is-lean,.sub.is-lean,.cat.is-lean{
  transition:transform .09s linear;
  transform:perspective(900px)
            rotateX(var(--rx,0deg)) rotateY(var(--ry,0deg))
            translateZ(0)}
.wp.is-lean .wp-img{
  object-position:calc(50% + var(--px,0px)) calc(var(--fy,15%) + var(--py,0px))}
@media (prefers-reduced-motion:reduce){
  .wp.is-lean,.sub.is-lean,.cat.is-lean{transform:none}
  .wp.is-lean .wp-img{object-position:50% var(--fy,15%)}}

.wp-sheen{position:absolute;inset:0;pointer-events:none;opacity:0;
  background:linear-gradient(115deg,transparent 32%,
    color-mix(in srgb,var(--tone) 40%,transparent) 50%,transparent 68%);
  transform:translateX(-60%);transition:opacity .3s}
.wp:hover .wp-sheen{opacity:.5;animation:sheen 1.1s var(--ease)}
@keyframes sheen{to{transform:translateX(60%)}}
.wp-frame::after{content:"";position:absolute;inset:0;pointer-events:none;
  box-shadow:inset 0 0 0 1px transparent;transition:box-shadow .35s}
.wp:hover .wp-frame::after{box-shadow:inset 0 0 0 1px var(--tone),
  inset 0 -70px 60px -50px color-mix(in srgb,var(--tone) 55%,transparent)}
/* A moving wallpaper looked exactly like a still in the grid, so the only way
   to find one was the Motion filter. A small mark says it at a glance. The dot
   does not pulse: forty cards each running a loop to say "live" is the kind
   of simultaneous motion the phone budget exists to prevent. */
.wp[data-kind="live"] .wp-frame::before{content:"Live";position:absolute;left:10px;top:10px;
  z-index:2;display:flex;align-items:center;gap:6px;padding:5px 9px 5px 8px;
  font-size:9.5px;letter-spacing:.2em;text-transform:uppercase;color:var(--white);
  background:rgba(5,4,9,.72);border:1px solid color-mix(in srgb,var(--tone) 55%,transparent);
  pointer-events:none}
.wp-meta{display:flex;justify-content:space-between;align-items:baseline;gap:12px;
  padding:14px 2px 0}
.wp-meta b{color:var(--white);font-size:15.5px;font-variation-settings:"wdth" 106,"wght" 700;
  transition:color .3s}
.wp:hover .wp-meta b{color:var(--tone)}
.wp-meta em{font-style:normal;font-size:10px;letter-spacing:.22em;text-transform:uppercase;
  color:var(--slate)}
.wp-get{position:absolute;left:14px;bottom:64px;padding:8px 16px;font-size:10.5px;
  letter-spacing:.2em;text-transform:uppercase;color:var(--void);background:var(--tone);
  opacity:0;transform:translateY(10px);transition:opacity .4s var(--ease),transform .4s var(--ease)}
.wp:hover .wp-get{opacity:1;transform:none}

/* ==========================================================================
   Motion vocabulary
   Fades read as cheap. These are the two moves the whole site now uses: text
   rises out from behind a clipped edge, and images are wiped in under a mask
   while the picture inside them settles from a slight scale. Both are one
   transform plus one clip, so they stay on the compositor.
   ========================================================================== */
.mask-head,.mask-line{clip-path:inset(0 0 110% 0);transform:translateY(.5em);
  opacity:0;transition:clip-path 1.05s var(--ease),transform 1.05s var(--ease),opacity .5s;
  transition-delay:var(--d,0s)}
/* The resting clip has to clear every halo the text carries. It was
   inset(-.25em 0 -.25em 0) - nothing at the sides - while headings carry a
   40-70px glow (.d1's violet drop-shadow, the dark halo over a wall, a topic's
   tone) and copy over a wall an 18px one. The clip cut each soft glow into a
   hard-edged box: a dark slab behind "Attack on Titan", a strip behind every
   line of the hub's lede. 90px clears the widest of them. */
.mask-head.in,.mask-line.in{clip-path:inset(-90px);transform:none;opacity:1}
/* An inline element cannot carry either half of this. A transform does not
   apply to an inline box at all, and clip-path on an inline that wraps clips
   to its FIRST line — "What is wrong with the alternatives" came out as "What
   is wrong with the" on a phone, the rest cut away. inline-block keeps it in
   the line and gives the mask a box to work on. */
span.mask-line,span.mask-head{display:inline-block;max-width:100%}

/* Cards rise into place while the picture inside settles from a slight
   scale. This was a clip-path wipe, and clip-path is not a compositor
   property: every frame of every card's reveal was a repaint on the main
   thread. One card is nothing; a grid of forty arriving as you flick down a
   phone is exactly the stutter people saw. Opacity and transform run on the
   GPU and look nearly the same at card size. The text masks keep their clip
   — a page has a handful of headings, not forty.

   translateY, never translate3d, for a RESTING offset. A 3D transform
   promotes its element to a compositor layer for as long as it holds it —
   measured: 179 layers on the phone hub against 44 before, one per unrevealed
   card and one per card's hidden caption. A 2D transform is promoted only
   while it is actually transitioning, which is the only time it needs to be. */
[data-img-reveal]{position:relative;overflow:hidden;
  opacity:0;transform:translateY(26px);
  transition:opacity .75s var(--ease),transform 1s var(--ease);
  transition-delay:calc(var(--i,0) * .06s)}
[data-img-reveal].in{opacity:1;transform:none}
[data-img-reveal] img{transform:scale(1.1);transition:transform 1.4s var(--ease);
  transition-delay:calc(var(--i,0) * .06s)}
[data-img-reveal].in img{transform:scale(1)}
/* hover scale is owned by .wp-img, so let it win once the reveal has run */
@media(hover:hover){.wp:hover [data-img-reveal].in img{transform:scale(1.07)}}

/* the grid leans with scroll velocity — cheap, and the single strongest
   "this site is alive" cue there is */
[data-skew]{will-change:transform}

@media(prefers-reduced-motion:reduce){
  .mask-head,.mask-line{clip-path:none;transform:none;opacity:1;transition:none}
  [data-img-reveal]{opacity:1;transform:none;transition:none}
  [data-img-reveal] img{transform:none;transition:none}
  [data-skew]{transform:none!important}
}

/* ------------------------------------------------------- page curtain
   A short violet wipe over the first paint, with the mark held in it. It is
   removed from the DOM once it has run, and never shown to a crawler or to
   anyone with reduced motion. */
.curtain{position:fixed;inset:0;z-index:400;background:var(--void);
  display:grid;place-items:center;pointer-events:none;
  transition:clip-path 1s var(--ease),opacity .5s .55s;
  clip-path:inset(0 0 0 0)}
.curtain.up{clip-path:inset(0 0 100% 0);opacity:0}
.curtain .mk{width:54px;height:54px;overflow:visible;
  animation:curtainMk 1.4s var(--ease) infinite alternate}
@keyframes curtainMk{from{opacity:.35;transform:scale(.94)}to{opacity:1;transform:scale(1)}}

/* ------------------------------------------------------- entry gate */
.gate{border:1px solid var(--line);background:linear-gradient(160deg,var(--panel),var(--base));
  padding:clamp(24px,3.4vw,40px);position:relative;overflow:hidden}
.gate::before{content:"";position:absolute;inset:0;pointer-events:none;
  background:radial-gradient(600px circle at var(--mx,50%) var(--my,50%),var(--iris-dim),transparent 62%)}
.gate>*{position:relative}
.gate-q{font-size:clamp(18px,2.2vw,25px);color:var(--white);margin-bottom:20px;
  font-variation-settings:"wdth" 108,"wght" 700}
.gate-opts{display:grid;grid-template-columns:repeat(2,1fr);gap:9px}
@media(max-width:560px){.gate-opts{grid-template-columns:1fr}}
.gate-out{margin-top:24px;padding-top:22px;border-top:1px solid var(--line);
  opacity:0;transform:translateY(14px);transition:opacity .6s var(--ease),transform .6s var(--ease)}
.gate-out.in{opacity:1;transform:none}
.gate-out h3{color:var(--iris-lt);margin-bottom:10px}
.gate-out .btn{margin-top:18px}

/* --------------------------------------------------------- entry portal
   The door you knock on before the site opens. Asked once, remembered in
   localStorage, and skippable — it is a greeting, not a paywall. Built by JS
   so crawlers and no-JS visitors get the page itself with nothing in front. */
.portal{position:fixed;inset:0;z-index:200;display:grid;place-items:center;
  padding:clamp(18px,4vw,40px);opacity:0;transition:opacity .5s var(--ease)}
.portal.in{opacity:1}
.portal.out{opacity:0;pointer-events:none}
.portal-bg{position:absolute;inset:0;background:rgba(4,4,7,.93);
  backdrop-filter:blur(26px) saturate(1.2)}
/* A door that parts rather than a box that fades in. The leaves start closed
   over the whole viewport and split down the seam; everything inside then
   rises on its own --pd delay. */
.portal-door{position:absolute;inset:0;pointer-events:none;overflow:hidden}
.portal-leaf{position:absolute;top:0;bottom:0;width:50.5%;background:
  linear-gradient(180deg,#0A0A12,#050508);
  transition:transform 1.15s var(--ease)}
.portal-leaf-l{left:0;transform:translateX(0)}
.portal-leaf-r{right:0;transform:translateX(0)}
.portal.in .portal-leaf-l{transform:translateX(-101%)}
.portal.in .portal-leaf-r{transform:translateX(101%)}
.portal-seam{position:absolute;left:50%;top:0;bottom:0;width:1px;
  background:linear-gradient(180deg,transparent,var(--iris),transparent);
  opacity:.9;transition:opacity .5s .5s}
.portal.in .portal-seam{opacity:0}
/* everything inside rises out from behind a clipped edge, staggered */
.portal-r{opacity:0;transform:translateY(16px);
  transition:opacity .7s var(--ease),transform .7s var(--ease);
  transition-delay:var(--pd,0s)}
.portal.in .portal-r{opacity:1;transform:none}
.portal-in{position:relative;width:100%;max-width:720px;text-align:center;
  border:1px solid var(--line);background:linear-gradient(160deg,var(--panel),var(--base));
  padding:clamp(28px,4.4vw,54px);
  box-shadow:0 40px 120px rgba(0,0,0,.8),0 0 70px rgba(168,85,247,.1);
  transform:translateY(18px) scale(.985);transition:transform .6s var(--ease)}
.portal.in .portal-in{transform:none}
.portal-mk{width:52px;height:52px;margin:0 auto 20px;overflow:visible}
.portal-q{font-size:clamp(24px,3.6vw,42px);color:var(--white);margin:12px 0 0;
  font-variation-settings:"wdth" 114,"wght" 850;letter-spacing:-.025em;line-height:1.02;
  text-transform:uppercase}
.portal-sub{margin-top:14px;color:var(--steel);font-size:15px;max-width:46ch;
  margin-left:auto;margin-right:auto}
.portal-opts{display:grid;grid-template-columns:repeat(4,1fr);gap:9px;
  margin-top:clamp(24px,3vw,34px);text-align:left}
.portal-opts button{padding:16px 15px;border:1px solid var(--edge);background:var(--panel);
  color:var(--silver);text-align:left;cursor:pointer;
  transition:border-color .28s,background .28s,color .28s,transform .4s var(--ease)}
.portal-opts button b{display:block;font-size:15px;color:var(--white);
  font-variation-settings:"wdth" 108,"wght" 750;letter-spacing:.01em}
.portal-opts button span{display:block;margin-top:5px;font-size:12px;color:var(--slate);line-height:1.45}
.portal-opts button:hover{border-color:var(--iris);background:var(--iris-dim);transform:translateY(-3px)}
.portal-opts button:hover b{color:var(--iris-lt)}
@media(max-width:720px){.portal-opts{grid-template-columns:repeat(2,1fr)}}
@media(max-width:420px){.portal-opts{grid-template-columns:1fr}}
.portal-skip{margin-top:22px;background:none;border:0;color:var(--slate);cursor:pointer;
  font-size:11.5px;letter-spacing:.18em;text-transform:uppercase;padding:8px;
  transition:color .25s}
.portal-skip:hover{color:var(--iris)}
.portal-ans h3{color:var(--iris-lt);font-size:clamp(20px,2.6vw,30px);margin-bottom:12px}
.portal-ans .lede{margin:0 auto}
.portal-ans .portal-go{display:flex;flex-wrap:wrap;gap:12px;justify-content:center;margin-top:26px}
@media(prefers-reduced-motion:reduce){
  .portal,.portal-in,.portal-opts button{transition:none}
  .portal-in{transform:none}
}

/* ------------------------------------------------------- tilt cards */
[data-tilt]{transition:transform .5s var(--ease),background .35s;transform-style:preserve-3d;
  position:relative;overflow:hidden}
[data-tilt]::after{content:"";position:absolute;inset:0;pointer-events:none;opacity:0;
  transition:opacity .35s;background:radial-gradient(400px circle at var(--mx,50%) var(--my,50%),
  rgba(168,85,247,.13),transparent 60%)}
[data-tilt]:hover::after{opacity:1}
[data-magnet]{transition:transform .4s var(--ease),color .3s,box-shadow .3s}

/* scroll-linked scale for section headings */
[data-scene].lift .lift-in{transform:translateY(calc((1 - var(--p,0)) * 40px));
  opacity:calc(.3 + var(--p,0) * .7)}

@media(prefers-reduced-motion:reduce){
  .split-w>span{transform:none;opacity:1}
  [data-stagger]>*,[data-grow]{opacity:1;transform:none}
  .scroll-bar{display:none}
}

/* -------------------------------------------------------- dagger cursor */
.has-dagger,.has-dagger *{cursor:none}
/* keep the native caret where people actually need it */
.has-dagger.no-dagger,.has-dagger input,.has-dagger textarea,
.has-dagger select,.has-dagger [contenteditable]{cursor:auto}
/* 28x52 at 1:1 with the SVG viewBox, so transform-origin can name the blade
   tip in user units and the point stays welded to the real pointer position. */
.dagger{position:fixed;left:0;top:0;width:26px;height:54px;z-index:9999;pointer-events:none;
  will-change:transform;transition:opacity .18s}
.dagger svg{width:100%;height:100%;filter:drop-shadow(0 0 7px rgba(168,85,247,.7))
  drop-shadow(0 2px 4px rgba(0,0,0,.6));transform-origin:3px 2px;
  transition:transform .12s var(--ease)}
.dagger.strike svg{transform:scale(.86) rotate(-13deg)}
.dagger.off{opacity:0}
.dagger-trail{position:fixed;inset:0;z-index:9998;pointer-events:none}
/* interactive elements get a subtle lift instead of a cursor change */
.has-dagger a:hover,.has-dagger button:hover,.has-dagger .opt:hover{filter:brightness(1.08)}
@media(hover:none),(pointer:coarse){.dagger,.dagger-trail{display:none}}
@media(prefers-reduced-motion:reduce){
  .has-dagger,.has-dagger *{cursor:auto}
  .dagger,.dagger-trail{display:none}
}

/* ------------------------------------------------------- vault dropdown */
.dd{position:relative}
.dd-top{display:inline-flex;align-items:center;gap:7px}
.dd-caret{width:5px;height:5px;border-right:1.4px solid currentColor;border-bottom:1.4px solid currentColor;
  transform:rotate(45deg) translateY(-2px);transition:transform .28s}
.dd:hover .dd-caret,.dd.open .dd-caret{transform:rotate(225deg) translateY(-1px)}
.dd-panel{position:absolute;top:calc(100% + 14px);left:50%;transform:translate(-50%,10px);
  min-width:520px;background:rgba(8,8,12,.97);backdrop-filter:blur(22px);
  border:1px solid var(--line);padding:22px;opacity:0;visibility:hidden;
  transition:opacity .28s var(--ease),transform .28s var(--ease),visibility .28s;
  box-shadow:0 26px 70px rgba(0,0,0,.7),0 0 40px rgba(168,85,247,.09)}
.dd:hover .dd-panel,.dd:focus-within .dd-panel,.dd.open .dd-panel{
  opacity:1;visibility:visible;transform:translate(-50%,0)}
.dd-grid{display:grid;grid-template-columns:1fr 1fr;gap:22px}
.dd-col h4{font-size:9.5px;letter-spacing:.28em;text-transform:uppercase;color:var(--iris);
  margin:0 0 12px;padding-bottom:9px;border-bottom:1px solid var(--line-soft);
  font-variation-settings:"wdth" 100,"wght" 600}
.dd-item{display:block;padding:9px 10px;margin:0 -10px;text-decoration:none;
  border-left:1px solid transparent;transition:background .22s,border-color .22s}
.dd-item::after{display:none!important}
.dd-item b{display:block;font-size:13.5px;color:var(--silver);letter-spacing:0;
  text-transform:none;font-variation-settings:"wdth" 102,"wght" 600}
.dd-item span{display:block;font-size:11.5px;color:var(--slate);letter-spacing:0;
  text-transform:none;margin-top:2px}
.dd-item:hover{background:var(--iris-dim);border-left-color:var(--iris)}
.dd-item:hover b{color:var(--iris-lt)}
.dd-all{display:block;margin-top:18px;padding-top:14px;border-top:1px solid var(--line-soft);
  font-size:11px;letter-spacing:.18em;text-transform:uppercase;color:var(--iris);text-decoration:none}
.dd-all::after{display:none!important}
@media(max-width:1040px){
  .dd-panel{position:static;transform:none;min-width:0;opacity:1;visibility:visible;
    border:0;padding:0 0 10px;box-shadow:none;background:none;backdrop-filter:none;
    display:none}
  .dd.open .dd-panel{display:block}
  .dd-grid{grid-template-columns:1fr;gap:14px}
  .dd-item{margin:0;padding:11px 0;border-left:0;border-bottom:1px solid var(--line-soft)}
  .dd-top{width:100%;justify-content:space-between}
}

/* ==========================================================================
   Tool identities
   All three shared one template and felt interchangeable. Each now gets its
   own accent, output shape and layout so they read as separate instruments.
   ========================================================================== */

/* -- 01 Rate calculator: cold steel, a single verdict number -------------- */
.t-rate{--tone:#8B5CF6;--tone-lt:#C4B5FD}
.t-rate .out{background:linear-gradient(165deg,#0E0C18,#08080C);
  border-color:rgba(139,92,246,.3)}
.t-rate .out-big{color:#fff;text-shadow:0 0 48px rgba(139,92,246,.55)}
.t-rate .out-sub{color:var(--tone-lt)}
.t-rate .tool-mark{position:absolute;top:20px;right:20px;width:34px;height:34px;
  border:1px solid rgba(139,92,246,.4);display:grid;place-items:center;color:var(--tone-lt);
  font-size:11px;font-variation-settings:"wdth" 112,"wght" 800}

/* -- 02 Income goal: a funnel that narrows, stage by stage ---------------- */
.t-goal{--tone:#06B6D4;--tone-lt:#67E8F9}
.t-goal .label,.t-goal .crumb a,.t-goal .field span{color:var(--tone-lt)}
.t-goal .input:focus{border-color:var(--tone);box-shadow:0 0 0 3px rgba(6,182,212,.16)}
.t-goal .out{background:linear-gradient(165deg,#04141A,#08080C);border-color:rgba(6,182,212,.3)}
.t-goal .out-big{color:#fff;text-shadow:0 0 48px rgba(6,182,212,.5)}
.t-goal .out-sub{color:var(--tone-lt)}
.funnel{margin-top:24px;display:flex;flex-direction:column;gap:8px}
.fn-row{position:relative;border:1px solid rgba(6,182,212,.24);background:rgba(6,182,212,.06);
  padding:13px 16px;display:flex;justify-content:space-between;align-items:center;
  transition:width .5s var(--ease),background .3s}
.fn-row:nth-child(1){width:100%}
.fn-row:nth-child(2){width:82%}
.fn-row:nth-child(3){width:64%}
.fn-row span{font-size:11px;letter-spacing:.16em;text-transform:uppercase;color:var(--tone-lt)}
.fn-row b{font-size:17px;color:#fff;font-variation-settings:"wdth" 110,"wght" 800}
.fn-row::after{content:"";position:absolute;left:0;top:0;bottom:0;width:2px;background:var(--tone);
  box-shadow:0 0 12px rgba(6,182,212,.7)}
@media(max-width:920px){.fn-row:nth-child(2){width:88%}.fn-row:nth-child(3){width:76%}}

/* -- 03 Runway: a depleting bar, warm because it's about time running out - */
.t-run{--tone:#F59E0B;--tone-lt:#FCD34D}
.t-run .label,.t-run .crumb a,.t-run .field span{color:var(--tone-lt)}
.t-run .input:focus{border-color:var(--tone);box-shadow:0 0 0 3px rgba(245,158,11,.16)}
.t-run .out{background:linear-gradient(165deg,#191006,#08080C);border-color:rgba(245,158,11,.3)}
.t-run .out-big{color:#fff;text-shadow:0 0 48px rgba(245,158,11,.45)}
.t-run .out-sub{color:var(--tone-lt)}
.months{display:grid;grid-template-columns:repeat(12,1fr);gap:4px;margin-top:24px}
.mo{aspect-ratio:1/2.4;border:1px solid rgba(245,158,11,.2);background:rgba(245,158,11,.05);
  transition:background .4s var(--ease),border-color .4s,box-shadow .4s}
.mo.on{background:linear-gradient(180deg,var(--tone),#B45309);border-color:var(--tone);
  box-shadow:0 0 12px rgba(245,158,11,.4)}
.mo.part{background:linear-gradient(180deg,rgba(245,158,11,.5),transparent);border-color:rgba(245,158,11,.5)}
.months-key{display:flex;justify-content:space-between;margin-top:10px;font-size:10.5px;
  letter-spacing:.14em;text-transform:uppercase;color:var(--slate)}
.t-rate .btn,.t-goal .btn,.t-run .btn{border-color:var(--tone);color:var(--tone-lt)}
.t-rate .btn::before,.t-goal .btn::before,.t-run .btn::before{background:var(--tone)}
.t-rate .btn:hover,.t-goal .btn:hover,.t-run .btn:hover{color:#08080C}
.t-goal .tick li::before,.t-run .tick li::before{background:var(--tone)}
.tool-nav a[aria-current]{color:var(--tone,var(--iris));border-color:var(--tone,var(--iris))}


/* ==========================================================================
   The gateway
   The click animation between wallpaper pages. A link marked [data-portal]
   does not navigate straight away: a full-screen layer opens from the exact
   point you clicked, in the tone of the thing you clicked, plays one of five
   effects chosen by the category, and only then loads the next page. The page
   you land on plays the second half of the same move, so a hard navigation
   reads as one continuous door opening.

   Every element here is built by kv.js and removed afterwards. Nothing loops,
   and under prefers-reduced-motion the whole thing is skipped and the browser
   simply follows the link.
   ========================================================================== */
.gw{position:fixed;inset:0;z-index:9000;pointer-events:none;overflow:hidden;
  --tone:#A855F7;--x:50%;--y:50%}
.gw-ink{position:absolute;left:var(--x);top:var(--y);width:10px;height:10px;
  margin:-5px 0 0 -5px;border-radius:50%;transform:scale(0);
  background:radial-gradient(circle,color-mix(in srgb,var(--tone) 34%,#050508) 0%,
    #050508 62%);
  transition:transform .46s cubic-bezier(.7,0,.3,1)}
.gw.on .gw-ink{transform:scale(var(--reach,60))}
.gw-ring{position:absolute;left:var(--x);top:var(--y);width:80px;height:80px;
  margin:-40px 0 0 -40px;border-radius:50%;opacity:0;
  border:1px solid color-mix(in srgb,var(--tone) 70%,transparent);
  box-shadow:0 0 40px color-mix(in srgb,var(--tone) 45%,transparent)}
.gw.on .gw-ring{animation:gwRing .62s var(--ease) forwards}
@keyframes gwRing{0%{opacity:.9;transform:scale(.2)}100%{opacity:0;transform:scale(14)}}
.gw-name{position:absolute;left:0;right:0;top:50%;text-align:center;
  transform:translateY(-50%);color:var(--tone);font-size:clamp(22px,4.4vw,52px);
  font-variation-settings:"wdth" 116,"wght" 900;letter-spacing:-.02em;
  opacity:0;clip-path:inset(0 0 110% 0)}
.gw.on .gw-name{animation:gwName .6s .12s var(--ease) forwards}
@keyframes gwName{
  0%{opacity:0;clip-path:inset(0 0 110% 0);transform:translateY(-40%)}
  55%{opacity:1;clip-path:inset(-.3em 0 -.3em 0);transform:translateY(-50%)}
  100%{opacity:1;clip-path:inset(-.3em 0 -.3em 0);transform:translateY(-54%)}}

/* the five effects. Each is a set of plain divs kv.js positions; the movement
   is one transform each, so a burst of forty of them still composites. */
.gw-fx{position:absolute;left:var(--x);top:var(--y);width:0;height:0}
.gw-fx i{position:absolute;display:block;background:var(--tone);opacity:0;
  transform-origin:0 50%}
.gw.on .gw-fx i{animation:var(--anim) var(--dur,.6s) var(--ease) var(--d,0s) forwards}
@keyframes gwShard{0%{opacity:0;transform:rotate(var(--a)) scaleX(0)}
  30%{opacity:.9}100%{opacity:0;transform:rotate(var(--a)) scaleX(1)}}
@keyframes gwWarp{0%{opacity:0;transform:rotate(var(--a)) translateX(4vmax) scaleX(.1)}
  25%{opacity:1}100%{opacity:0;transform:rotate(var(--a)) translateX(72vmax) scaleX(1)}}
@keyframes gwBloom{0%{opacity:0;transform:rotate(var(--a)) translateX(0) scale(0)}
  40%{opacity:.85}100%{opacity:0;transform:rotate(var(--a)) translateX(46vmax) scale(1)}}
@keyframes gwScan{0%{opacity:0;transform:translate(var(--sx),var(--sy)) scaleX(0)}
  35%{opacity:.8}100%{opacity:0;transform:translate(calc(var(--sx) * -1),var(--sy)) scaleX(1)}}
@keyframes gwSweep{0%{opacity:0;transform:rotate(var(--a)) scaleX(0)}
  40%{opacity:.55}100%{opacity:0;transform:rotate(var(--a)) scaleX(1)}}

/* arrival: the same layer, already covering, dissolving outward. The tone and
   the name come across in sessionStorage, which is why the two halves match. */
.gw-in .gw-ink{transform:scale(var(--reach,60));
  transition:transform .8s var(--ease),opacity .55s .12s}
.gw-in.off .gw-ink{transform:scale(0);opacity:0}
.gw-in .gw-name{opacity:1;clip-path:inset(-.3em 0 -.3em 0);
  transition:opacity .4s,transform .7s var(--ease)}
.gw-in.off .gw-name{opacity:0;transform:translateY(-58%)}

@media(prefers-reduced-motion:reduce){.gw{display:none}}

/* ==========================================================================
   The viewer
   One picture, and the panel that hands it over. The picture is the focus:
   on a phone it takes everything above a compact sheet, on a wide screen
   everything left of the panel. kv.js sizes .lb-pic to the picture's own
   shape, so nothing here guesses an aspect ratio, and the crop frame drawn
   inside it lands on the picture rather than on letterbox bars.

   There is no backdrop-filter. The backdrop used to be 94% opaque AND
   blurred, which bought nothing anybody could see and made a phone re-blur
   the page behind it whenever anything back there moved. It is an opaque
   room now, lit in the wallpaper's own tone.
   ========================================================================== */
/* The download button's three icons, on :root because .lb-get is the site's
   one Download control - the house wallpaper pages use it outside the viewer. */
:root{
  --ic-dl:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2.4' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M12 4v12M6 10l6 6 6-6M5 20h14'/%3E%3C/svg%3E");
  --ic-ok:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2.6' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M5 12.5l4.5 4.5L19 7.5'/%3E%3C/svg%3E");
  --ic-again:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2.4' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M20 12a8 8 0 1 1-2.3-5.6M20 4v5h-5'/%3E%3C/svg%3E")}
.lb{position:fixed;inset:0;z-index:8000;display:grid;
  grid-template-columns:minmax(0,1fr);grid-template-rows:minmax(0,1fr) auto;
  --tone:var(--iris);--side:clamp(300px,25vw,360px);
  opacity:0;transition:opacity .3s var(--ease)}
.lb.on{opacity:1}
.lb:focus{outline:none}
/* `.lb > .lb-bg`, never a bare `.lb-bg`: that has the same specificity as
   `.lb > *` below, and when the later rule won, the backdrop became a stray
   grid item - the page showed through the viewer and the picture was pushed
   out of its track. One selector, three symptoms. */
.lb > .lb-bg{position:absolute;inset:0;
  background:radial-gradient(85% 65% at 40% 42%,
    color-mix(in srgb,var(--tone) 11%,#07060C),#040307 74%);
  opacity:calc(1 - var(--pull,0) * .7)}
.lb > *{position:relative}
.lb > .lb-say{position:absolute;width:1px;height:1px;margin:-1px;overflow:hidden;
  clip:rect(0 0 0 0);white-space:nowrap}
body.lb-open{overflow:hidden}

/* ---- the picture. kv.js places .lb-pic so the size you chose is centred and
   as large as the stage allows; the stage clips whatever falls outside it.
   clip, not hidden: nothing here should become a scroll container. */
.lb-stage{grid-area:1/1;min-width:0;min-height:0;overflow:hidden;overflow:clip;
  padding:62px 10px 12px;touch-action:none;-webkit-user-select:none;user-select:none}
.lb-pic{position:absolute;left:0;top:0;overflow:hidden;background:var(--panel);
  box-shadow:0 34px 100px -24px rgba(0,0,0,.85),0 0 0 1px rgba(239,235,247,.07);
  transform:perspective(1800px) rotateX(var(--lrx,0deg)) rotateY(var(--lry,0deg))
            scale(var(--lsc,1));
  /* geometry is the zoom between sizes; tilt is the mouse lean. Each is a
     variable so a state can retime one without restating the rest. */
  --geo:left .55s var(--ease),top .55s var(--ease),width .55s var(--ease),
    height .55s var(--ease);
  --tilt:transform .5s var(--ease);
  transition:var(--geo),translate .45s var(--ease),opacity .35s var(--ease),
    scale .55s var(--ease),var(--tilt)}
.lb:not(.on) .lb-pic{scale:.96;opacity:0}
.lb.is-drag .lb-pic{transition:none}
/* a new picture or a resized window lands at once; only a chosen size zooms */
.lb.is-swap .lb-pic{--geo:left 0s,top 0s,width 0s,height 0s}
/* pointer tracking has to be immediate; the long ease is for opening */
.lb.is-lean .lb-pic{--tilt:transform .1s linear}
.lb-pic > img,.lb-pic > video{position:absolute;inset:0;width:100%;height:100%;
  object-fit:cover;display:block;-webkit-user-drag:none}
.lb-img,.lb-vid{opacity:0;transition:opacity .5s var(--ease)}
.lb-img.is-in,.lb-vid.is-in{opacity:1}

/* A line of the tone sliding along the foot of the picture while the full
   file is on its way. The thumbnail is already up, so this is the only sign
   that something sharper is coming. */
.lb-load{position:absolute;left:0;right:0;bottom:0;height:2px;overflow:hidden;
  pointer-events:none;opacity:0;transition:opacity .4s}
.lb-pic.is-loading .lb-load{opacity:1}
.lb-load::before{content:"";position:absolute;top:0;bottom:0;left:0;width:36%;
  background:linear-gradient(90deg,transparent,var(--tone),transparent)}
.lb-pic.is-loading .lb-load::before{animation:lbLoad 1.15s cubic-bezier(.45,0,.55,1) infinite}
@keyframes lbLoad{from{transform:translateX(-100%)}to{transform:translateX(280%)}}

/* The crop about to be taken: everything outside it dimmed, its edge in the
   tone, its name in the corner. */
.lb-crop{position:absolute;left:0;top:0;width:100%;height:100%;pointer-events:none;
  box-shadow:0 0 0 200vmax rgba(4,3,8,.64);
  outline:1px solid color-mix(in srgb,var(--tone) 70%,#fff);outline-offset:-1px;
  transition:left .5s var(--ease),top .5s var(--ease),width .5s var(--ease),
    height .5s var(--ease),opacity .35s var(--ease)}
.lb-crop.is-off{opacity:0}
.lb.is-swap .lb-crop{transition:none}
.lb-crop i{position:absolute;left:0;top:0;padding:5px 8px;font-style:normal;
  font-size:10px;letter-spacing:.16em;text-transform:uppercase;color:var(--void);
  background:color-mix(in srgb,var(--tone) 75%,#fff);
  font-variation-settings:"wdth" 110,"wght" 700}

/* previous / next at the picture's edges, on wide screens; a phone swipes */
.lb-edge{position:absolute;top:50%;width:52px;height:88px;margin-top:-44px;padding:0;
  border:0;background:none;color:var(--steel);cursor:pointer;display:none;
  opacity:.5;transition:opacity .25s,color .25s}
.lb-edge::before{content:"";display:block;width:13px;height:13px;margin:auto;
  border-left:1.5px solid currentColor;border-bottom:1.5px solid currentColor;
  transform:translateX(3px) rotate(45deg)}
.lb-next::before{transform:translateX(-3px) rotate(-135deg)}
.lb-prev{left:4px}
.lb-next{right:4px}
.lb-edge:hover,.lb-edge:focus-visible{opacity:1;color:var(--white)}

/* ---- count and close: over the picture on a phone, atop the panel when wide */
.lb-top{grid-area:1/1;align-self:start;z-index:2;display:flex;align-items:center;
  justify-content:space-between;gap:10px;padding:8px 8px 0;pointer-events:none;
  opacity:calc(1 - var(--pull,0))}
.lb-top > *{pointer-events:auto}
.lb-nav{display:flex;align-items:center}
.lb-nav button,.lb-x{width:44px;height:44px;display:grid;place-items:center;padding:0;
  border:1px solid transparent;background:rgba(8,7,14,.5);color:var(--steel);
  font:inherit;font-size:22px;line-height:1;cursor:pointer;
  transition:color .25s,border-color .25s,transform .3s var(--ease)}
.lb-x{font-size:16px}
.lb-nav button:hover,.lb-x:hover{color:var(--white);border-color:var(--edge)}
@media(hover:hover){.lb-x:hover{transform:rotate(90deg)}}
.lb-count{min-width:62px;text-align:center;font-size:11px;letter-spacing:.18em;
  color:var(--steel);font-variant-numeric:tabular-nums}

/* ---- the panel: a sheet under the picture on a phone */
.lb-panel{grid-area:2/1;display:grid;gap:12px;align-content:start;min-width:0;
  padding:2px 14px calc(14px + env(safe-area-inset-bottom,0px));
  opacity:calc(1 - var(--pull,0));
  transition:translate .5s var(--ease) .05s,opacity .4s var(--ease) .05s}
.lb:not(.on) .lb-panel{translate:0 16px;opacity:0}
.lb-info{min-width:0}
.lb-where{margin:0 0 2px;font-size:10px;letter-spacing:.2em;text-transform:uppercase;
  color:var(--tone);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.lb-title{margin:0;font-size:16px;line-height:1.25;letter-spacing:0;color:var(--white);
  font-variation-settings:"wdth" 106,"wght" 740;
  white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.lb-words,.lb-label{display:none}

/* Every size at once, never a row that scrolls some of them out of sight:
   the choice has to be obvious before anybody taps. Three across on a phone,
   one row on a tablet. */
.lb-sizes{display:grid;grid-template-columns:repeat(auto-fit,minmax(104px,1fr));gap:7px}
.lb-opt{display:grid;grid-template-columns:20px minmax(0,1fr);grid-template-rows:auto auto;
  column-gap:8px;align-items:center;min-width:0;padding:8px 9px;
  border:1px solid var(--edge);background:rgba(13,11,22,.7);color:var(--slate);
  font:inherit;text-align:left;cursor:pointer;
  transition:border-color .25s,background .25s,color .25s}
.lb-gl{grid-row:1/3;width:20px;height:20px;display:grid;place-items:center}
.lb-gl i{display:block;border:1.5px solid currentColor;transition:background .25s}
.lb-gl i.is-play{width:0;height:0;border:0;border-left:9px solid currentColor;
  border-top:6px solid transparent;border-bottom:6px solid transparent;margin-left:3px}
.lb-opt b,.lb-opt small{min-width:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.lb-opt b{font-size:12.5px;font-weight:inherit;color:var(--silver);
  font-variation-settings:"wdth" 104,"wght" 650}
.lb-opt small{font-size:10.5px;color:var(--slate);font-variant-numeric:tabular-nums}
@media(hover:hover){.lb-opt:hover{border-color:color-mix(in srgb,var(--tone) 50%,var(--edge))}}
.lb-opt[aria-checked="true"]{border-color:var(--tone);color:var(--tone);
  background:color-mix(in srgb,var(--tone) 13%,#0D0B16)}
.lb-opt[aria-checked="true"] b{color:var(--white)}
.lb-opt[aria-checked="true"] .lb-gl i:not(.is-play){
  background:color-mix(in srgb,var(--tone) 30%,transparent)}
.lb-opt:focus-visible,.lb-seg button:focus-visible,.lb-get:focus-visible,
.lb-nav button:focus-visible,.lb-x:focus-visible,.lb-edge:focus-visible{
  outline:2px solid var(--tone);outline-offset:2px}

/* What you are about to get, in one line - and while it happens, what is
   happening. One line for both, so a status never pushes the picture up. */
.lb-spec{margin:9px 0 0;min-height:1.45em;font-size:11.5px;line-height:1.45;
  color:var(--steel);font-variant-numeric:tabular-nums}
.lb-spec i{font-style:normal;color:var(--slate);margin:0 3px}
.lb-spec a{color:var(--tone);text-underline-offset:3px}

.lb-act{display:flex;gap:8px;align-items:stretch}
.lb-seg{display:flex;flex:none;border:1px solid var(--edge)}
.lb-seg button{min-width:52px;padding:0 10px;border:0;background:none;color:var(--slate);
  font:inherit;font-size:10.5px;letter-spacing:.12em;cursor:pointer;
  transition:color .2s,background .2s}
.lb-seg button[aria-checked="true"]{color:var(--white);
  background:color-mix(in srgb,var(--tone) 24%,transparent)}

/* The one solid thing in the viewer: the action it exists for. */
.lb-get{position:relative;overflow:hidden;flex:1;display:flex;align-items:center;
  justify-content:center;gap:9px;min-height:50px;padding:0 20px;
  border:1px solid var(--tone);background:var(--tone);color:var(--void);
  text-decoration:none;font-size:13px;letter-spacing:.06em;white-space:nowrap;
  font-variation-settings:"wdth" 110,"wght" 780;cursor:pointer;
  box-shadow:0 12px 34px -14px color-mix(in srgb,var(--tone) 80%,transparent);
  transition:box-shadow .3s,transform .3s var(--ease),background .3s,color .3s,
    border-color .3s}
.lb-get::before{content:"";position:relative;z-index:1;width:15px;height:15px;flex:none;
  background:currentColor;
  -webkit-mask:var(--ic-dl) center/contain no-repeat;mask:var(--ic-dl) center/contain no-repeat}
.lb-get-t{position:relative;z-index:1;font-variant-numeric:tabular-nums}
/* progress is a transform on one layer, so drawing it costs nothing */
.lb-get-bar{position:absolute;inset:0;background:rgba(255,255,255,.32);
  transform:scaleX(var(--p,0));transform-origin:0 50%;opacity:0;
  transition:transform .2s linear,opacity .3s}
.lb-get.is-going{cursor:progress}
.lb-get.is-going .lb-get-bar{opacity:1}
.lb-get.is-done::before{-webkit-mask-image:var(--ic-ok);mask-image:var(--ic-ok)}
.lb-get.is-ready{background:var(--white);border-color:var(--white)}
.lb-get.is-error{background:transparent;color:var(--white);box-shadow:none}
.lb-get.is-error::before{-webkit-mask-image:var(--ic-again);mask-image:var(--ic-again)}
@media(hover:hover){.lb-get:hover{transform:translateY(-2px);
  box-shadow:0 18px 44px -14px color-mix(in srgb,var(--tone) 90%,transparent)}}
.lb-get:active{transform:scale(.98)}

/* ---- wide screens, and any screen turned on its side: picture left, panel
   right. A phone held sideways is 844 x 390, and a sheet under the picture
   would leave the picture 180px tall. */
@media (min-width:1100px),(min-width:640px) and (min-aspect-ratio:5/4){
  .lb{grid-template-columns:minmax(0,1fr) var(--side);grid-template-rows:minmax(0,1fr)}
  .lb-stage{padding:clamp(18px,4vh,40px) clamp(48px,4.4vw,72px)}
  .lb-edge{display:block}
  .lb-top{grid-area:1/2;padding:16px 16px 0 22px}
  .lb-nav button{display:none}
  .lb-count{min-width:0;text-align:left}
  /* The list may scroll; the button may not. On a short screen the sizes
     give way and Download stays where a thumb or a cursor expects it. */
  .lb-panel{grid-area:1/2;display:flex;flex-direction:column;gap:22px;overflow:hidden;
    padding:78px 22px 22px;border-left:1px solid var(--line-soft);
    background:linear-gradient(180deg,rgba(13,11,22,.6),rgba(8,7,14,.88))}
  .lb-info,.lb-act,.lb-keys{flex:none}
  .lb-choose{flex:0 1 auto;min-height:0;overflow-y:auto;overscroll-behavior:contain;
    margin:0 -6px;padding:0 6px 2px}
  .lb:not(.on) .lb-panel{translate:20px 0}
  .lb-where{white-space:normal}
  .lb-title{font-size:clamp(19px,1.5vw,23px);line-height:1.2;white-space:normal}
  .lb-words{display:block;margin:10px 0 0;font-size:13px;line-height:1.55;color:var(--steel)}
  .lb-label{display:block;margin:0 0 10px;font-size:10px;letter-spacing:.2em;
    text-transform:uppercase;color:var(--slate)}
  .lb-sizes{grid-template-columns:1fr;gap:6px}
  .lb-opt{grid-template-columns:24px minmax(0,1fr) auto;grid-template-rows:auto;
    column-gap:10px;padding:10px 12px}
  .lb-gl{grid-row:auto;width:24px;height:24px}
}
/* A phone on its side (844 x 390): two across and one line each - the spec
   line already says the size of the one chosen - so all of it fits. */
@media (min-width:640px) and (min-aspect-ratio:5/4) and (max-height:560px){
  .lb-top{padding-top:8px}
  .lb-panel{gap:10px;padding-top:58px;padding-bottom:12px}
  .lb-title{white-space:nowrap}
  .lb-words,.lb-label,.lb-keys{display:none}
  .lb-sizes{grid-template-columns:1fr 1fr}
  .lb-opt{grid-template-columns:20px minmax(0,1fr);padding:8px 9px}
  .lb-opt small{display:none}
  .lb-gl{width:20px;height:20px}
}
/* the keys, for the people who have them */
.lb-keys{display:none;margin:0;font-size:10.5px;letter-spacing:.06em;color:var(--slate)}
.lb-keys kbd{font:inherit;color:var(--steel);border:1px solid var(--edge);
  padding:1px 6px;margin-right:4px}
.lb-keys span{display:inline-block;width:14px}
@media (hover:hover) and (pointer:fine) and (min-width:1100px) and (min-height:561px){
  .lb-keys{display:block;margin-top:auto}
}

@media(prefers-reduced-motion:reduce){
  .lb,.lb-pic,.lb-panel,.lb-crop,.lb-img,.lb-vid{transition:opacity .2s linear}
  .lb .lb-pic,.lb:not(.on) .lb-pic{transform:none;scale:none}
  .lb:not(.on) .lb-panel{translate:none}
  .lb-pic.is-loading .lb-load::before{animation:none}
}


/* ==========================================================================
   Living plates
   The card art is drawn (art_scenes.py) and then moved here. Rain falls, fire
   flickers, water rolls, stars breathe. Nothing runs until kv.js marks the
   card .on — with 190 of them on one page, animating the ones nobody is
   looking at is how you turn a wallpaper site into a space heater.

   The site's own rule is that motion is scroll- or pointer-driven and nothing
   loops. These loop, deliberately: a picture of rain that does not rain is
   just a drawing of lines. They are slow, low-amplitude and off entirely
   under prefers-reduced-motion.
   ========================================================================== */
.plate-live{--lit:0}
/* will-change ONLY while the card is actually animating — note the .on.
   Without it every scene layer on the page is promoted to its own compositor
   layer and kept there: 108 of them on /wallpapers.html, none of which are
   moving until you reach them. A desktop absorbs that. A phone does not — it
   runs out of layer memory and the compositor's answer is to rasterise at
   reduced resolution and drop frames, so the animations that DO run look soft
   and stutter. The promise of will-change is "I am about to animate this";
   making it on 108 idle elements is a lie the compositor pays for. */
.plate-live.on .plate-svg [class^="an-"]{will-change:transform,opacity}

/* every layer breathes a little, at a rate set by its depth — this is what
   stops a still scene reading as clip art */
.plate-live.on [data-depth]{animation:anBreathe 11s ease-in-out infinite alternate}
.plate-live.on [data-depth="1.35"],
.plate-live.on [data-depth="1.4"],
.plate-live.on [data-depth="1.5"]{animation-duration:7s}
.plate-live.on [data-depth=".2"],
.plate-live.on [data-depth=".12"]{animation-duration:16s}
@keyframes anBreathe{from{transform:translate3d(-1.2px,.6px,0)}
  to{transform:translate3d(1.2px,-.6px,0)}}

/* the named behaviours. Each overrides the breathe above. */
.plate-live.on .an-drift{animation:anDrift 14s ease-in-out infinite alternate}
@keyframes anDrift{from{transform:translate3d(-7px,0,0)}to{transform:translate3d(7px,-3px,0)}}

.plate-live.on .an-rain{animation:anRain 1.1s linear infinite}
@keyframes anRain{from{transform:translate3d(2px,-16px,0);opacity:0}
  18%{opacity:1}82%{opacity:1}to{transform:translate3d(-2px,16px,0);opacity:0}}

.plate-live.on .an-fall{animation:anFall 6.5s linear infinite}
@keyframes anFall{from{transform:translate3d(-4px,-14px,0);opacity:0}
  22%{opacity:1}78%{opacity:1}to{transform:translate3d(4px,16px,0);opacity:0}}

.plate-live.on .an-roll{animation:anRoll 6s ease-in-out infinite alternate}
@keyframes anRoll{from{transform:translate3d(-6px,1px,0)}to{transform:translate3d(6px,-1px,0)}}

.plate-live.on .an-flick{animation:anFlick 2.2s steps(6,end) infinite;transform-origin:50% 80%}
@keyframes anFlick{0%{transform:scale(1,1) translateY(0);opacity:.85}
  25%{transform:scale(1.05,.94) translateY(1px);opacity:1}
  50%{transform:scale(.95,1.06) translateY(-1px);opacity:.8}
  75%{transform:scale(1.03,.97);opacity:1}
  100%{transform:scale(1,1);opacity:.85}}

.plate-live.on .an-pulse{animation:anPulse 4.2s ease-in-out infinite alternate;
  transform-origin:50% 50%;transform-box:fill-box}
@keyframes anPulse{from{transform:scale(.86);opacity:.55}to{transform:scale(1.1);opacity:1}}

.plate-live.on .an-twinkle{animation:anTwinkle 3.4s ease-in-out infinite alternate}
@keyframes anTwinkle{from{opacity:.35}to{opacity:1}}

.plate-live.on .an-dash{animation:anDash 1.5s linear infinite}
@keyframes anDash{from{transform:translate3d(-26px,0,0);opacity:0}
  20%{opacity:1}80%{opacity:1}to{transform:translate3d(120px,0,0);opacity:0}}

.plate-live.on .an-wave{animation:anWave 9s ease-in-out infinite alternate}
@keyframes anWave{from{transform:translate3d(-8px,2px,0) scaleY(.94)}
  to{transform:translate3d(8px,-2px,0) scaleY(1.06)}}

.plate-live.on .an-spin{animation:anSpin 48s linear infinite;
  transform-origin:50% 50%;transform-box:fill-box}
@keyframes anSpin{to{transform:rotate(360deg)}}

/* hovering a card leans into it: the picture warms and speeds up slightly */
.cat:hover .plate-live.on [class^="an-"],
.sub:hover .plate-live.on [class^="an-"]{animation-duration:.85s}
.cat:hover .plate-live.on .an-drift,.sub:hover .plate-live.on .an-drift,
.cat:hover .plate-live.on .an-spin,.sub:hover .plate-live.on .an-spin{animation-duration:9s}
.plate-live .plate-svg{transition:transform 1.4s var(--ease),filter .6s}
.cat:hover .plate-live .plate-svg,.sub:hover .plate-live .plate-svg{
  transform:scale(1.06);filter:saturate(1.25) brightness(1.12)}

/* the second colour is what stops every card being violet. It is set per
   card by build.py from the topic palette. */
.plate-live{color:var(--tone,var(--iris))}
.plate-live .plate-glow{background:radial-gradient(220px circle at var(--px,50%) var(--py,50%),
  color-mix(in srgb,var(--tone2,var(--iris)) 26%,transparent),transparent 68%)}

@media(prefers-reduced-motion:reduce){
  .plate-live.on [data-depth],.plate-live.on [class^="an-"]{animation:none!important}
  .cat:hover .plate-live .plate-svg,.sub:hover .plate-live .plate-svg{transform:none}
}


/* ==========================================================================
   Type, sharpened
   The system was already one superfamily on a width axis. What it was missing
   was the finish: display type that catches light instead of sitting flat,
   headings that break where a person would break them, and small type with
   enough tracking to feel deliberate rather than default.

   No second typeface. The brand rule stands — hierarchy still comes out of
   Archivo's width axis. This is the same voice, better lit.
   ========================================================================== */

/* Display type is filled with a vertical gradient rather than a flat white:
   bright at the cap line, cooling into violet at the baseline, as though the
   light in the room is above it. -webkit-text-fill-color rather than color,
   so the many places that set `color:var(--white)` on a .d1 do not silently
   flatten it back out. */
.d1,.d2,.kh-title,.hero-copy .d1{
  background-image:linear-gradient(174deg,#FFFFFF 6%,#F3EDFF 38%,#C9A0FA 78%,#9B6BE0 100%);
  -webkit-background-clip:text;background-clip:text;
  -webkit-text-fill-color:transparent;
  /* a gradient needs a painted box; keep it tight to the glyphs */
  padding-bottom:.04em}
/* one caveat: gradient text cannot also carry a text-shadow glow, so the glow
   goes behind it instead, on the block itself */
.d1{filter:drop-shadow(0 0 42px rgba(174,98,250,.22))}
.d2{filter:drop-shadow(0 0 30px rgba(174,98,250,.16))}
/* If background-clip:text is not supported, transparent fill means every
   display heading on the site renders as nothing. A page that animates badly
   is survivable; a page with no headings is not — same rule as the reveal
   engine. Put the colour back rather than trusting the browser. */
/* A split heading has to carry the gradient on every WORD. background-clip:
   text only clips to the element's own glyphs; the words of a [data-split]
   heading are inline-block spans in their own boxes, so they inherited the
   transparent fill and none of the gradient — "Everything inside", the Vault
   page's title, has been invisible on every device since it was split. */
.split-host .split-w>span{
  background-image:linear-gradient(174deg,#FFFFFF 6%,#F3EDFF 38%,#C9A0FA 78%,#9B6BE0 100%);
  -webkit-background-clip:text;background-clip:text;
  -webkit-text-fill-color:transparent}
@supports not ((-webkit-background-clip:text) or (background-clip:text)){
  .d1,.d2,.kh-title,.hero-copy .d1,.split-host .split-w>span{
    -webkit-text-fill-color:currentColor;background-image:none;filter:none}}

/* Break headings like a person, not like a container. One line of CSS and
   the single biggest legibility win available to a display face. */
h1,h2,h3,h4,.d1,.d2,.d3{text-wrap:balance}
p,.lede,.cat-peek,.sub-blurb{text-wrap:pretty}

/* Optical tracking: big type wants less, small type wants more. */
.d1{letter-spacing:-.042em}
.d2{letter-spacing:-.032em}
.label{letter-spacing:.36em;font-variation-settings:"wdth" 96,"wght" 600}
.lede{color:var(--steel);line-height:1.72;font-variation-settings:"wdth" 102,"wght" 380}
.hero-tag{font-variation-settings:"wdth" 98,"wght" 520}

/* Body copy at a comfortable measure and a slightly warmer weight. */
body{font-variation-settings:"wdth" 100,"wght" 380}
strong,b{font-variation-settings:"wdth" 104,"wght" 700}

/* A light travels across a display heading once, as it arrives. It is tied to
   the reveal class, so it happens on scroll like everything else and never
   loops. */
.mask-head.in{animation:typeSheen 1.5s var(--ease) .25s both}
@keyframes typeSheen{
  from{background-position:0 -1.6em}
  to{background-position:0 0}}

/* Links inside prose get a violet underline that draws itself. */
.lede a,.prose a,p a{color:var(--iris-lt);text-decoration:none;
  background-image:linear-gradient(var(--iris),var(--iris));
  background-repeat:no-repeat;background-position:0 100%;background-size:0 1px;
  transition:background-size .45s var(--ease),color .3s}
.lede a:hover,.prose a:hover,p a:hover{background-size:100% 1px;color:var(--white)}

/* The two supporting accents, used only where something is genuinely a
   different kind of thing: a live count, a warning, a second state. */
.accent-cy{color:var(--cyan)}
.accent-ro{color:var(--rose)}

@media(prefers-reduced-motion:reduce){
  .mask-head.in{animation:none}
  .lede a,.prose a,p a{transition:none}}


/* ==========================================================================
   Real media
   The moment a real picture or a video lands in assets/wallpapers/, the card
   stops being a drawing and becomes that. This is the treatment that makes an
   uploaded image arrive looking like it belongs to this site rather than like
   a photo dropped onto it: a slow drift, a violet grade, a vignette, and the
   same glow every other card has.

   None of it is destructive — it is all overlay and filter, so the file you
   uploaded is still the file people download.
   ========================================================================== */
.plate-real{overflow:hidden;background:#05040A}
.plate-real .plate-img{width:100%;height:100%;object-fit:cover;object-position:50% 15%;
  display:block;transform:scale(1.08)}
/* No filter. brightness(.94) under a grade at .92 is what made the covers
   read as muddy boxes — Space was nearly black — and a CSS filter on an image
   that is also animating makes the browser redraw the filtered result as it
   moves, on every card. */

/* the drift: a very slow push in and across, so a still image is never still.
   Only while the card is on screen — kv.js owns the .on class. */
.plate-real.on .plate-img{animation:plateDrift 26s ease-in-out infinite alternate}
@keyframes plateDrift{
  from{transform:scale(1.08) translate3d(-1.4%,0.9%,0)}
  to{transform:scale(1.17) translate3d(1.4%,-0.9%,0)}}

/* the grade: the house violet laid over the top, plus a vignette so the card
   has a centre. This is what stops twelve different anime screenshots from
   looking like twelve different websites. */
.plate-grade{position:absolute;inset:0;pointer-events:none;
  background:
    linear-gradient(180deg,rgba(5,4,9,.12) 0%,rgba(5,4,9,.62) 88%),
    radial-gradient(120% 90% at 50% 40%,transparent 42%,rgba(5,4,9,.72) 100%),
    linear-gradient(160deg,color-mix(in srgb,var(--tone,var(--iris)) 22%,transparent),
      transparent 46%,color-mix(in srgb,var(--tone2,var(--iris-dp)) 26%,transparent));
  mix-blend-mode:normal;opacity:.55;transition:opacity .6s var(--ease)}
.cat:hover .plate-grade,.sub:hover .plate-grade,.cell-art:hover .plate-grade{opacity:.3}

/* video covers behave exactly like image ones */
.plate-vid .plate-img{object-fit:cover;transition:opacity .6s var(--ease)}
/* kv.js paints the poster onto the plate and hides the video until it is
   really playing — otherwise a starting video is a black box */
.plate-vid{background-size:cover;background-position:center}
.plate-vid.vid-wait .plate-img{opacity:0}
.plate-vid.on .plate-img{animation:none}

@media(prefers-reduced-motion:reduce){
  .plate-real.on .plate-img{animation:none;transform:scale(1.04)}}


/* ==========================================================================
   Kiro, alive — behaviour only
   Nothing here touches his face. The rule (CLAUDE.md) is that an overlay on
   flat art must survive a two-percent placement error, and no eyelid, pupil
   or catchlight does. So none of this paints on him: it moves him, lights
   him, and gives him weight.

   Six things, all driven by where you are and what you are doing:
     breath    a slow rise and fall, always running
     turn      the whole figure rotates toward the pointer
     light     a violet rim that sits where your cursor is
     sheen     a highlight that travels his edge as he turns
     attend    he leans toward whatever you are about to click
     weight    he lags behind a fast scroll, then settles
   ========================================================================== */

/* --lit is how close you are, --turn is which way. Both written by kv.js. */
[data-rig]{--lit:0;--turn:0}

/* The light on him. A large soft violet source positioned at the pointer,
   screen-blended so it reads as light falling on him rather than a shape
   drawn over him — which is exactly the test the face overlays failed. */
.rig-light{position:absolute;inset:-10%;pointer-events:none;mix-blend-mode:screen;
  opacity:calc(.18 + var(--lit,0) * .55);
  background:radial-gradient(38% 34% at var(--lx,60%) var(--ly,34%),
    rgba(212,180,255,.5),rgba(174,98,250,.28) 42%,transparent 72%);
  transition:opacity .7s var(--ease)}

/* The sheen: a hard, narrow band of light that slides along him as he turns.
   It is masked to his silhouette by sitting inside the rig, and it only shows
   while he is actually moving toward you. */
.rig-sheen{position:absolute;inset:0;pointer-events:none;mix-blend-mode:screen;
  opacity:calc(var(--lit,0) * .5);
  background:linear-gradient(calc(102deg + var(--turn,0) * 26deg),
    transparent 40%,rgba(233,213,255,.28) 50%,transparent 60%);
  transform:translate3d(calc(var(--turn,0) * 5%),0,0);
  transition:opacity .6s var(--ease),transform 1s var(--ease)}

/* He turns. Small numbers on purpose — past about four degrees a flat image
   stops reading as a person turning and starts reading as a picture skewing. */
[data-rig].alive{
  transform:rotate(calc(var(--turn,0) * 1.1deg))
            translate3d(calc(var(--turn,0) * -9px),calc(var(--lit,0) * -6px),0)
            scale(calc(1 + var(--lit,0) * .012));
  transition:transform 1.3s var(--ease)}

/* …and he has weight: a fast scroll leaves him behind for a moment. */
[data-rig].alive{--drag:0}
[data-rig].alive .rig-body{transform:translate3d(0,calc(var(--drag,0) * 1px),0)
  rotate(calc(var(--drag,0) * .04deg));transition:transform .9s var(--ease)}

/* He attends to what you are about to press. kv.js adds .attend while the
   pointer is over a button or a card, and he leans that way. */
[data-rig].attend{filter:brightness(1.08) saturate(1.12)}

@media(prefers-reduced-motion:reduce){
  [data-rig].alive,[data-rig].alive .rig-body{transform:none;transition:none}
  .rig-light,.rig-sheen{display:none}}


/* ==========================================================================
   Motion everywhere
   The rule the site started with — scroll- or pointer-driven, nothing loops —
   was there to stop the page becoming a screensaver. It stays as the default.
   What was missing was the other half: every element that can be touched
   should answer when you touch it. None of the below runs on a timer; all of
   it is a response to something you did.
   ========================================================================== */

/* ------------------------------------------------------- buttons answer */
/* a hard light crosses the button as the pointer enters it */
.btn{--shx:50%}
.btn::after{content:"";position:absolute;inset:0;pointer-events:none;z-index:1;
  background:linear-gradient(104deg,transparent 38%,rgba(255,255,255,.22) 50%,transparent 62%);
  transform:translateX(-120%);transition:transform .7s var(--ease);mix-blend-mode:screen}
.btn:hover::after{transform:translateX(120%)}
.btn:active{transform:translateY(1px) scale(.985);transition-duration:.09s}
.btn-solid:hover{box-shadow:0 0 44px var(--iris-glow),0 10px 40px rgba(0,0,0,.5)}

/* ---------------------------------------------------------- nav answers */
.nav-menu a{position:relative;transition:color .3s var(--ease)}
.nav-menu a:hover{color:var(--white);text-shadow:0 0 18px var(--iris-glow)}
.nav-logo .mk{transition:transform .8s var(--ease),filter .5s}
.nav-logo:hover .mk{transform:rotate(-8deg) scale(1.08);
  filter:drop-shadow(0 0 14px var(--iris-glow))}
.nav-logo .wm{transition:letter-spacing .6s var(--ease)}
.nav-logo:hover .wm{letter-spacing:.02em}

/* ------------------------------------------------------- cards answer */
/* the lift is already there; this is the light that comes with it */
.cat,.sub,.wp,.cell{position:relative}
.cat::after,.sub::after{content:"";position:absolute;inset:0;pointer-events:none;
  opacity:0;transition:opacity .5s var(--ease);
  background:radial-gradient(300px circle at var(--mx,50%) var(--my,50%),
    color-mix(in srgb,var(--tone,var(--iris)) 14%,transparent),transparent 66%)}
.cat:hover::after,.sub:hover::after{opacity:1}
/* the name slides a hair when you hover — small enough to feel, not to see */
.cat-top b,.sub-top b{transition:color .3s,transform .5s var(--ease)}
.cat:hover .cat-top b,.sub:hover .sub-top b{transform:translateX(3px)}

/* --------------------------------------------------- inputs answer */
.input:hover,.in:hover{border-color:color-mix(in srgb,var(--iris) 40%,var(--edge))}
.input,.in{transition:border-color .3s,box-shadow .3s,background .3s}
.input:focus{background:var(--raise)}

/* -------------------------------------------------- section arrivals */
/* a hairline that draws itself across the top of a section as it arrives */
.sec-line{position:relative}
.sec-line::before{content:"";position:absolute;left:0;top:-1px;height:1px;width:0;
  background:linear-gradient(90deg,var(--iris),transparent);
  transition:width 1.6s var(--ease)}
.sec-line.lit::before{width:min(420px,60%)}

/* the label above a heading gets a leading rule that grows */
.head .label,.label.mask-line{position:relative;padding-left:0}
.head .label::before{content:"";position:absolute;left:-14px;top:50%;width:0;height:1px;
  background:var(--iris);transition:width .9s var(--ease) .2s}
.head .label.in::before{width:10px}

/* ------------------------------------------------------ crumbs, chips */
.chip,.wp-f{position:relative;overflow:hidden}
.chip::before,.wp-f::before{content:"";position:absolute;inset:0;background:var(--iris-dim);
  transform:scaleX(0);transform-origin:0 50%;transition:transform .45s var(--ease);z-index:-1}
.chip:hover::before,.wp-f:hover::before{transform:scaleX(1)}

/* ------------------------------------------------------- the counters */
.wp-stats b,.ab-num b,.stat b{transition:text-shadow .5s}
.wp-stats:hover b,.ab-num:hover b{text-shadow:0 0 30px var(--iris-glow)}

@media(prefers-reduced-motion:reduce){
  .btn::after,.cat::after,.sub::after{display:none}
  .sec-line::before,.head .label::before{transition:none}
  .nav-logo:hover .mk{transform:none}}


/* The WebGL surface sits exactly where the picture was. The <img> keeps its
   layout (hidden, not removed) so nothing reflows and the fallback is one
   line of JS away if the context is ever lost. */
.kiro-gl{position:absolute;left:0;top:0;width:100%;height:100%;display:block;
  pointer-events:none;z-index:1}
.rig{position:relative}


/* A nav link's transition is the same door moving faster. The ink has to be
   fully closed before the page leaves, so this timing and the setTimeout in
   kv.js are a pair — change one and you get a flash of the old page. */
.gw-quick .gw-ink{transition-duration:.28s}
.gw-quick .gw-ring{animation-duration:.4s}
.gw-quick .gw-name{display:none}

/* ==========================================================================
   The living ground
   Every page now sits on a slow violet weather system instead of flat black:
   three large soft masses drifting at different rates, far enough apart in
   speed that they never repeat visibly. It is one composited layer behind
   everything, costs nothing to animate, and is the difference between a dark
   page and a room with air in it.

   This is the one place the "nothing loops" rule is set aside on every page,
   deliberately and after being asked for it twice. It is behind z-index 0,
   under the grain, and off entirely under reduced motion.
   ========================================================================== */
/* THE BLUR IS IN THE GRADIENT, NOT IN A FILTER, and that is the whole
   performance story of this site on a phone.

   This used to be `filter:blur(60px) saturate(1.2)` over three moving
   children. A filter is not a compositor property: the browser has to
   re-rasterise the filtered subtree every time anything inside it moves — and
   inside it, three elements move forever. On a fixed layer 140% of the
   viewport, at the DPR 3 a phone reports, that is a 1.6k x 3.5k surface
   gaussian-blurred sixty times a second behind every page on the site.

   Measured on a phone-shaped Chrome with the CPU throttled 4x, home page,
   interleaved runs:

     as it was ............ idle frame 33ms   (30fps with NOTHING happening)
     no blur filter ....... idle frame 17ms
     no weather at all .... idle frame 17ms

   So the filter WAS the cost, and removing it buys back everything removing
   the weather entirely would. The look survives because a 60px blur of a
   radial gradient is just a softer radial gradient: the stops below are the
   blurred shape, drawn directly. A gradient rasterises once and then only
   moves, which is a compositor transform and free.

   Keep it that way. If you ever reach for `filter:` on something that moves,
   or on something with moving children, this is the paragraph to reread. */
.weather{position:fixed;inset:-20%;z-index:0;pointer-events:none;opacity:.62}
.weather i{position:absolute;display:block;border-radius:50%;mix-blend-mode:screen}
.weather i:nth-child(1){left:2%;top:6%;width:56vw;height:56vw;
  background:radial-gradient(circle,rgba(174,98,250,.26) 0%,
    rgba(174,98,250,.17) 26%,rgba(174,98,250,.07) 48%,
    rgba(174,98,250,.02) 66%,transparent 80%);
  animation:wx1 48s ease-in-out infinite alternate}
.weather i:nth-child(2){right:0%;top:30%;width:62vw;height:62vw;
  background:radial-gradient(circle,rgba(109,40,217,.25) 0%,
    rgba(109,40,217,.16) 26%,rgba(109,40,217,.07) 48%,
    rgba(109,40,217,.02) 66%,transparent 80%);
  animation:wx2 63s ease-in-out infinite alternate}
.weather i:nth-child(3){left:28%;bottom:-6%;width:50vw;height:50vw;
  background:radial-gradient(circle,rgba(94,233,240,.11) 0%,
    rgba(94,233,240,.07) 28%,rgba(94,233,240,.03) 50%,
    rgba(94,233,240,.01) 68%,transparent 82%);
  animation:wx3 77s ease-in-out infinite alternate}
@keyframes wx1{to{transform:translate3d(14vw,8vh,0) scale(1.18)}}
@keyframes wx2{to{transform:translate3d(-16vw,-10vh,0) scale(.86)}}
@keyframes wx3{to{transform:translate3d(10vw,-12vh,0) scale(1.22)}}
@media(prefers-reduced-motion:reduce){.weather{display:none}}

/* ==========================================================================
   Peek
   A tile showing one picture tells you almost nothing about a collection of
   forty. Hovering one cross-fades through the next few, so you can see what
   is actually inside without opening it.

   The extra images have no src until the pointer arrives — kv.js fills it in
   from data-shot on first hover. A category page carries forty tiles, and
   eagerly loading five images each would undo the whole thumbnail fix.
   ========================================================================== */
.plate-shot{position:absolute;inset:0;width:100%;height:100%;object-fit:cover;
  object-position:50% 15%;opacity:0;transition:opacity .55s var(--ease)}
.plate-shot.on{opacity:1}
/* the base picture fades out only while a peek is showing, so the tile is
   never blank between two shots */
.plate-real[data-shots].peeking>.plate-img:first-of-type{opacity:0;
  transition:opacity .55s var(--ease)}

/* a quiet mark that there is more behind this one, so the cross-fade is
   discoverable instead of a surprise */
.plate-real[data-shots]::after{content:"";position:absolute;right:10px;bottom:10px;
  width:6px;height:6px;border-radius:50%;background:var(--tone,var(--iris));
  box-shadow:0 0 0 3px rgba(5,4,9,.5),10px 0 0 0 rgba(239,235,247,.35),
    20px 0 0 0 rgba(239,235,247,.18);
  opacity:.85;transition:opacity .4s;z-index:2}
.cat:hover .plate-real[data-shots]::after,
.sub:hover .plate-real[data-shots]::after{opacity:0}

@media(prefers-reduced-motion:reduce){
  .plate-shot{transition:none}
  .plate-real[data-shots]::after{display:none}}

/* ==========================================================================
   The phone profile
   LAST IN THE FILE ON PURPOSE — these have to beat every rule above them,
   and a class adds no specificity that would do it from higher up.

   Everything switched off here loops FOREVER with no JavaScript gating it,
   which is why the site felt laggy on a phone with 8 GB of RAM while every
   individual effect looked innocent:

     .plate-svg        plBreathe, on every plate. 109 on /wallpapers.html.
     .plate [data-depth]  plFloat, on every depth layer inside every plate.
     .scene>span       the nine topic atmospheres, full-page. scRain runs a
                       1.5s linear loop over a tiled background across the
                       whole viewport — a continuous large-area repaint.

   The art does not disappear. It stops moving, which on a phone is the
   difference between a page that scrolls and one that does not. The mask
   reveals still run: they are one clip and one transform, they stay on the
   compositor, and each plays once.
   ========================================================================== */
/* Stopped: the MANY. A category page carries forty plates and the hub a
   hundred and nine, each an SVG plus several depth layers, and every one of
   them promoted to its own compositor layer. Past a phone's layer budget the
   browser does not fail — it rasterises at lower resolution and drops frames,
   which is what "the animations look bad" actually was.

   Animating `scale` on a complex inline SVG is also the one genuinely
   questionable thing here: unlike a bitmap it can force re-rasterisation at
   each step rather than staying on the compositor. */
/* LITE — a budget, not a ban.
   plBreathe and plFloat are written against EVERY .plate in the base CSS,
   with nothing gating them, which is how a hundred and nine of them came to
   animate at once. Here they are switched off wholesale and then given back
   only to the cards kv.js has marked .on — four at a time, chosen by whatever
   is nearest the middle of the screen. The page is alive wherever you are
   looking, and the layer count stays somewhere a phone can afford. */
html.kv-lite .plate-svg,
html.kv-lite .plate [data-depth]{animation:none}
html.kv-lite .plate-live.on .plate-svg{
  animation:plBreathe 14s ease-in-out infinite alternate}
html.kv-lite .plate-live.on [data-depth]{
  animation:plFloat 9s ease-in-out infinite alternate}

/* MINIMAL — <=4 GB, Save Data or 2G. The art stays, nothing moves but the
   reveals, which run once and stay on the compositor. */
html.kv-minimal .plate-svg,
html.kv-minimal .plate [data-depth],
html.kv-minimal .plate-live .plate-svg [class^="an-"],
html.kv-minimal .scene > span,
html.kv-minimal .scene::after{animation:none!important}

/* KEPT: the FEW. The page's own weather is three spans and a pseudo-element,
   it animates transform and opacity only, and it is most of what makes a One
   Piece page feel like One Piece. Three moving layers is not what made a
   phone struggle — four hundred did. Taking this away made the phone a
   poorer site for no measurable gain, which is the wrong trade. */

/* Two more full-screen layers a phone pays for and never uses.
   .grain is fixed, full-viewport and mix-blend-mode:overlay — the same cost
   as the mote field, blending the whole screen on every frame, for a texture
   at 3.5% opacity nobody can see on a 6-inch display.
   .pointer-aura is 600x600 with a 90px blur and will-change:transform, and
   kv.js only ever moves it on a fine pointer — so on a phone it is a large
   blurred layer that is rasterised and then sits there doing nothing. */
html.kv-light .grain,
html.kv-light .pointer-aura{display:none}

/* nothing should be promoted to its own compositor layer on a device that is
   short of layer memory — that is what made the animations that DID run
   rasterise at reduced resolution */
html.kv-light [data-skew]{will-change:auto}
/* Promote only what is actually animating. will-change on an idle element is
   a promise the compositor pays for and nothing redeems: 108 of them on the
   hub is what made the animations that DID run rasterise at reduced
   resolution. On lite, only the budgeted cards get it. */
html.kv-lite .plate-live:not(.on) .plate-svg [class^="an-"],
html.kv-minimal .plate-live .plate-svg [class^="an-"]{will-change:auto}

/* ==========================================================================
   Tiles — every category and collection card
   The picture is the card. Name and count sit on a scrim over the art rather
   than in a text block under it: that block made each card 430px tall on a
   phone and the hub 56 screens long. Markup lives in showcase.py.
   ========================================================================== */
.cat-grid,.sub-grid{display:grid;gap:clamp(8px,1.2vw,16px);
  grid-template-columns:repeat(2,minmax(0,1fr))}
@media(min-width:640px){.cat-grid,.sub-grid{grid-template-columns:repeat(3,minmax(0,1fr))}}
@media(min-width:1000px){.cat-grid,.sub-grid{grid-template-columns:repeat(4,minmax(0,1fr))}}
@media(min-width:1240px){.sub-grid{grid-template-columns:repeat(5,minmax(0,1fr))}}
.cat,.sub{position:relative;display:block;aspect-ratio:var(--ar-tile);
  text-decoration:none;color:var(--tone,var(--iris));isolation:isolate;
  /* The hub is 109 of these. Off screen the browser skips their style, layout
     and paint entirely. Measured on the phone hub, CPU throttled 4x, four
     paired runs: median dropped frames 51 -> 33, compositor layers ~72 -> 52.
     min-height:0 is what makes that safe. A box with an aspect ratio is never
     shorter than its content, and while it is skipped its content IS
     contain-intrinsic-size: every off-screen tile on a phone was 300px against
     214px on screen, and the hub shrank by 4,226px as it scrolled. Without
     that floor the ratio alone decides, rendered or not. */
  min-height:0;content-visibility:auto;contain-intrinsic-size:auto 300px}
.tile-in{position:absolute;inset:0;overflow:hidden;background:var(--panel);
  border:1px solid var(--line-soft)}
.tile-in>.plate,.tile-in>.forge-art{position:absolute;inset:0;height:100%;
  aspect-ratio:auto;border:0;transition:transform 1.1s var(--ease)}
.tile-scrim{position:absolute;inset:0;z-index:1;pointer-events:none;
  background:linear-gradient(to top,rgba(5,4,9,.94) 0%,rgba(5,4,9,.62) 28%,
    rgba(5,4,9,0) 56%)}
/* the peek is one line, so the body hides it by sliding exactly that far */
.tile-body{position:absolute;left:0;right:0;bottom:0;z-index:2;
  padding:clamp(12px,1.4vw,18px);display:flex;flex-direction:column;gap:4px;
  transform:translateY(24px);transition:transform .55s var(--ease)}
.tile-name{color:var(--white);font-size:clamp(15px,1.45vw,20px);line-height:1.15;
  font-variation-settings:"wdth" 110,"wght" 780;letter-spacing:-.005em;
  overflow-wrap:anywhere}
.tile-meta{font-size:10px;letter-spacing:.16em;text-transform:uppercase;
  color:color-mix(in srgb,var(--tone,var(--iris)) 62%,var(--silver));
  /* one line: a second one slides under the hidden peek and is cut in half */
  white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.tile-meta.soon{color:var(--slate)}
/* One line, two ways of saying it. A category tile carries both a count of
   collections and a count of pictures, and at two tiles across on a phone the
   pair does not fit — it used to be cut after the number, so "42 collections
   &middot; 113" ended on a bare figure. */
.tile-meta i{font-style:normal}
.m-slim{display:none}
@media(max-width:620px){.m-wide{display:none}.m-slim{display:inline}}
.cat-sm .m-wide{display:none}
.cat-sm .m-slim{display:inline}
.tile-peek{display:block;height:20px;font-size:12.5px;line-height:20px;color:var(--steel);
  white-space:nowrap;overflow:hidden;text-overflow:ellipsis;opacity:0;
  transition:opacity .4s var(--ease)}
.cat-rim{position:absolute;inset:0;z-index:3;pointer-events:none;opacity:0;
  transition:opacity .4s var(--ease);
  box-shadow:inset 0 0 0 1px color-mix(in srgb,var(--tone,var(--iris)) 62%,transparent),
    0 26px 70px -38px color-mix(in srgb,var(--tone,var(--iris)) 70%,transparent)}
@media(hover:hover){
  .cat:hover .tile-in>.plate,.sub:hover .tile-in>.plate,
  .cat:hover .tile-in>.forge-art{transform:scale(1.06)}
  .cat:hover .tile-body,.sub:hover .tile-body{transform:none}
  .cat:hover .tile-peek,.sub:hover .tile-peek{opacity:1}
  .cat:hover .cat-rim,.sub:hover .cat-rim{opacity:1}
}
.cat-sm .tile-peek{display:none}
/* ON TOUCH THE PEEK IS SIMPLY SHOWN, because there is no hover to show it
   with. The body is translated down 24px and the peek sits in that gap at
   opacity 0, waiting for a pointer — so on a phone every tile reserved the
   space and then never revealed the one line that says what is on the shelf
   ("Stills / Noir / Western" under Cinema). A phone visitor could not get
   that information by any means. Same rule as the watcher and the video
   covers: where the interaction does not exist, give the result away. */
@media(hover:none){
  .tile-body{transform:none}
  .tile-peek{opacity:.86}
}
/* exactly eight tiles (the home page's worlds): four across from a tablet up,
   so they close into two full rows instead of three-three-two */
@media(min-width:640px){.cat-grid-4{grid-template-columns:repeat(4,minmax(0,1fr))}}
.cat-grid-sm{grid-template-columns:repeat(2,minmax(0,1fr))}
@media(min-width:760px){.cat-grid-sm{grid-template-columns:repeat(4,minmax(0,1fr))}}
/* the forge tile: no shelf behind it, so its art is the orb */
.cat-forge .tile-in{background:linear-gradient(180deg,var(--iris-dim),transparent 64%),var(--panel);
  border-color:color-mix(in srgb,var(--iris) 30%,transparent)}
.cat-forge .forge-art{color:var(--iris-lt)}
/* filtering replays the entrance, staggered by new position */
.cat.pop,.sub.pop{animation:popIn .5s var(--ease) both;
  animation-delay:calc(var(--i,0) * .035s)}
@keyframes popIn{from{opacity:0;transform:translateY(12px) scale(.985)}
  to{opacity:1;transform:none}}
@media(prefers-reduced-motion:reduce){
  .tile-in>.plate,.tile-body,.tile-peek{transition:none}
  .tile-body{transform:none}
  .tile-peek{opacity:1}
  .cat.pop,.sub.pop{animation:none}
}

/* ==========================================================================
   The wall — real wallpapers drifting behind a hero (showcase.py)
   Each row is one element moving one transform, so a wall is three layers
   however many pictures it holds. kv.js adds .vw-off when it is off screen.
   ========================================================================== */
.vw{position:absolute;inset:0;overflow:hidden;pointer-events:none;z-index:0;
  --vw-w:clamp(150px,19vw,300px);--vw-g:clamp(8px,.9vw,14px)}
.vw-tilt{position:absolute;left:50%;top:50%;width:150%;display:flex;
  flex-direction:column;gap:var(--vw-g);transform:translate(-50%,-50%) rotate(-7deg)}
.vw-row{display:flex}
.vw-track{display:flex;flex:none;animation:vwSlide var(--dur,70s) linear infinite}
.vw-row:nth-child(even) .vw-track{animation-direction:reverse}
/* margin, not gap: with gap the two copies differ by half a gap and the loop
   jumps once per cycle */
.vw-t{position:relative;flex:none;display:block;width:var(--vw-w);aspect-ratio:16/10;
  margin-right:var(--vw-g);overflow:hidden;background:var(--panel)}
.vw-t img{width:100%;height:100%;object-fit:cover;object-position:50% var(--fy,15%)}
@keyframes vwSlide{to{transform:translate3d(-50%,0,0)}}
.vw.vw-off .vw-track,.vw.vw-held .vw-track,.vw.vw-touched .vw-track,
body.lb-open .vw-track,
.vw-links:focus-within .vw-track{animation-play-state:paused}
/* a wall someone chose to stop stays stopped; one tabbed into holds still
   while they are in it, so a link is not sliding away from the focus ring */
.sc-actions{display:flex;align-items:center;gap:10px;flex-wrap:wrap}
.vw-pause{display:inline-flex;align-items:center;gap:9px;padding:12px 16px;cursor:pointer;
  background:transparent;border:1px solid var(--edge);color:var(--steel);
  font-size:11px;letter-spacing:.18em;text-transform:uppercase;
  transition:color .25s,border-color .25s}
.vw-pause:hover{color:var(--white);border-color:var(--slate)}
.vw-pause-ic{width:10px;height:12px;flex:none;
  border-left:3px solid currentColor;border-right:3px solid currentColor}
.vw-pause[aria-pressed="true"] .vw-pause-ic{width:0;height:0;border-right:0;
  border-left:10px solid currentColor;border-top:6px solid transparent;border-bottom:6px solid transparent}
@media(prefers-reduced-motion:reduce){.vw-pause{display:none}}
/* on a phone the word goes and the button is the icon, so it shares a line
   with "Browse everything" instead of taking one of its own */
@media(max-width:600px){
  .vw-pause{width:46px;height:46px;justify-content:center;padding:0}
  .vw-pause-t{position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0)}
  .sc-actions{flex-wrap:nowrap}
}
html.kv-minimal .vw-track{animation:none}
@media(prefers-reduced-motion:reduce){.vw-track{animation:none}}

/* a hero standing in front of a wall */
.hw{position:relative;overflow:hidden;isolation:isolate;display:flex;align-items:flex-end;
  min-height:clamp(440px,70vh,700px);
  padding:clamp(110px,14vw,170px) 0 clamp(34px,5vw,64px);background:var(--void)}
.hw-sm{min-height:clamp(360px,54vh,560px);padding-top:clamp(96px,12vw,150px)}
.hw::after{content:"";position:absolute;inset:0;z-index:1;pointer-events:none;background:
  linear-gradient(180deg,rgba(5,4,9,.5) 0%,rgba(5,4,9,.28) 32%,rgba(5,4,9,.84) 68%,
    var(--void) 100%),
  linear-gradient(90deg,rgba(5,4,9,.78) 0%,rgba(5,4,9,.25) 58%,rgba(5,4,9,0) 100%),
  linear-gradient(0deg,color-mix(in srgb,var(--tone,var(--iris)) 14%,transparent),transparent 60%)}
.hw-in{position:relative;z-index:2;width:100%}
.hw .d1{color:var(--white);text-shadow:0 2px 40px rgba(5,4,9,.6)}
.hw .lede{color:var(--silver);max-width:62ch}
/* the hub's two actions. They had no rule at all, so on a phone they wrapped
   into each other: no gap, two different widths, touching the lede */
.wp-hero-cta{margin-top:clamp(26px,3.2vw,38px);display:flex;flex-wrap:wrap;gap:12px}
@media(max-width:560px){.wp-hero-cta{flex-direction:column}}
/* the small type over a wall — crumb, label, lede, stats — crosses bright
   pictures at 10-11px, where a dark halo is the difference between read and
   guessed; the display heading is large enough not to need one */
.hw .crumb,.hw .label,.hw .lede,.hw .wp-stats span{
  text-shadow:0 1px 2px rgba(5,4,9,.9),0 0 18px rgba(5,4,9,.85)}

/* the home page's wall is a showcase of its own: every tile opens its shelf */
.showcase{position:relative;padding:clamp(46px,7vw,96px) 0 clamp(26px,4vw,50px);overflow:hidden}
.sc-head{display:flex;justify-content:space-between;align-items:flex-end;gap:18px 30px;
  flex-wrap:wrap;margin-bottom:clamp(8px,2vw,20px)}
.sc-head .d2{margin-top:12px}
.vw-band{position:relative;inset:auto;pointer-events:auto;z-index:auto;
  padding:clamp(22px,3.4vw,46px) 0;--vw-w:clamp(190px,24vw,380px)}
.vw-band .vw-tilt{position:relative;left:auto;top:auto;width:auto;
  transform:rotate(-3deg) scale(1.08)}
.vw-links .vw-t{cursor:pointer}
.vw-cap{position:absolute;left:0;right:0;bottom:0;padding:26px 12px 10px;
  background:linear-gradient(to top,rgba(5,4,9,.9),transparent);
  font-size:11px;letter-spacing:.14em;text-transform:uppercase;color:var(--silver);
  opacity:0;transform:translateY(8px);
  transition:opacity .35s var(--ease),transform .35s var(--ease)}
.vw-cap b{font-weight:inherit}
.vw-links .vw-t img{transition:transform .8s var(--ease)}
@media(hover:hover){
  .vw-links .vw-row:hover .vw-track{animation-play-state:paused}
  .vw-links .vw-t:hover img{transform:scale(1.07)}
  .vw-links .vw-t:hover .vw-cap{opacity:1;transform:none}
}
@media(hover:none){.vw-cap{opacity:1;transform:none;font-size:9.5px;padding:18px 8px 7px}}
.vw-links .vw-t:focus-visible{outline-offset:-3px}

/* sibling shelves: a swipeable row on a phone, a grid where there is room */
/* Fixed track widths, not minmax(0,44%): ten columns of 44% cannot all fit,
   so a minmax track never grows past its 0 minimum and every card in the
   row collapsed to a 36px square. */
.rail{display:grid;grid-auto-flow:column;grid-auto-columns:44%;gap:10px;
  overflow-x:auto;scroll-snap-type:x mandatory;scrollbar-width:none;
  margin-inline:calc(-1 * var(--pad));padding:0 var(--pad) 4px;scroll-padding-inline:var(--pad)}
.rail::-webkit-scrollbar{display:none}
.rail>*{scroll-snap-align:start}
@media(min-width:640px){.rail{grid-auto-columns:30%}}
@media(min-width:1000px){.rail{grid-auto-flow:row;grid-template-columns:repeat(5,minmax(0,1fr));
  overflow:visible;margin-inline:0;padding:0;gap:14px}}
.rail-more{display:flex;justify-content:space-between;align-items:center;gap:16px;flex-wrap:wrap;
  margin-top:clamp(18px,2.4vw,28px)}
.rail-more a{font-size:11.5px;letter-spacing:.18em;text-transform:uppercase;color:var(--steel);
  text-decoration:none;transition:color .25s}
.rail-more a:hover{color:var(--tone,var(--iris))}

/* ==========================================================================
   Phones: wallpapers two across
   One full-width card per wallpaper put a single picture on each screen. Two
   across is how every gallery app on a phone works, and the title under each
   drops its repeated collection name — it is the page you are on.
   ========================================================================== */
@media(max-width:600px){
  .wp-grid{grid-template-columns:repeat(2,minmax(0,1fr));gap:12px 10px}
  .wp-meta{padding-top:8px;min-width:0}
  .wp-meta b{font-size:12.5px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;min-width:0}
  .wp-meta em{display:none}
  .wp-get{display:none}
}

/* ==========================================================================
   Motion, driven by the scroll itself
   The site revealed things once and then held still, which is what a motion
   site is not. Everything below is tied to scroll position — the page moves
   because you are moving it, continuously, in both directions.

   Why this is affordable on a phone when earlier motion was not: these are
   CSS scroll-driven animations (`animation-timeline`) animating ONLY the
   individual `translate`, `scale`, `rotate` and `opacity` properties. The
   browser runs them on the compositor thread, off the main thread, with no
   JavaScript per frame and no scroll listener. And because they use the
   individual properties rather than `transform`, they compose with the
   transforms already on these elements (the pointer lean, hover zooms, the
   JS parallax on Kiro) instead of silently overwriting them.

   Browsers without scroll timelines keep the one-shot reveals they had;
   reduced motion and the minimal tier get none of it.
   ========================================================================== */
@supports (animation-timeline: view()) {
@media (prefers-reduced-motion: no-preference) {

  /* overflow:CLIP, not hidden, on everything these animations sit inside.
     `overflow:hidden` makes an element a scroll container even though it
     never scrolls, and a view() timeline tracks its NEAREST scroll container
     — so every section, card frame and wall on the site was quietly the
     "scroller" for the animations inside it, and they sat frozen at one
     value however far the page moved. `clip` cuts exactly the same pixels
     and is not a scroll container. Page-level CSS loads after this file and
     sets hidden on the headers, hence the extra class on those two. */
  .sec,.sec-sm,.showcase,.vw,.hw,.hw.cat-hero,.hw.sub-hero,.tile-in,.wp-frame{overflow:clip}

  /* ---- heroes: the words lift away and thin out over the first screen of
          scroll, while the wall behind them leans in toward you */
  .hw>.hw-in,.hero-in,.kh-copy{
    animation:kvLift linear both;animation-timeline:scroll(root);animation-range:0 85vh}
  @keyframes kvLift{to{translate:0 -16vh;opacity:0;scale:.95}}
  .hw>.vw{animation:kvLean linear both;animation-timeline:scroll(root);animation-range:0 100vh}
  @keyframes kvLean{to{scale:1.24;translate:0 10vh}}
  .kh-stage{animation:kvRecede linear both;animation-timeline:scroll(root);
    animation-range:0 100vh}
  @keyframes kvRecede{to{scale:1.12;opacity:.3}}

  /* ---- Kiro rides the home page's first screen. He steps forward and turns
          into it, then lets it go: the site is his room, so the biggest piece
          of motion on the front page is him, not the furniture. */
  .hero .hero-art-wrap{animation:kvKiroRide linear both;
    animation-timeline:scroll(root);animation-range:0 110vh}
  @keyframes kvKiroRide{
    0%{scale:1;translate:0 0;rotate:0deg}
    38%{scale:1.07;translate:-2.5vw 1.5vh;rotate:-1.4deg}
    100%{scale:1.16;translate:1vw 9vh;rotate:1deg;opacity:.28}}
  /* his blurred echo travels the other way — the same trick the pointer uses,
     on the scroll instead, so he separates from himself as the page moves */
  .hero .hero-echo{animation:kvKiroEcho linear both;
    animation-timeline:scroll(root);animation-range:0 110vh}
  @keyframes kvKiroEcho{to{translate:5vw -2vh;scale:1.08}}
  /* the light on him grows as he comes forward, then goes out with him */
  .hero .hero-halo{animation:kvKiroHalo linear both;
    animation-timeline:scroll(root);animation-range:0 110vh}
  @keyframes kvKiroHalo{0%{opacity:.35;scale:.86}42%{opacity:.85;scale:1.06}
    100%{opacity:0;scale:1.2}}
  .hero .hero-rim{animation:kvKiroRim linear both;
    animation-timeline:scroll(root);animation-range:0 90vh}
  @keyframes kvKiroRim{0%{opacity:.5}45%{opacity:.95}100%{opacity:.2}}

  /* ---- wall rows answer the scroll on top of their own drift: odd rows run
          one way, even rows the other, so the wall shears as it passes */
  .vw-row{animation:kvRowA linear both;animation-timeline:view();animation-range:cover}
  .vw-row:nth-child(even){animation-name:kvRowB}
  @keyframes kvRowA{from{translate:9% 0}to{translate:-9% 0}}
  @keyframes kvRowB{from{translate:-9% 0}to{translate:9% 0}}
  /* a header's wall is already half way through its own pass when the page
     opens, so it would spend its whole shear in the first 250px; it takes
     the first screen of scroll instead, starting from rest */
  .hw .vw-row{animation-name:kvShearA;animation-timeline:scroll(root);animation-range:0 100vh}
  .hw .vw-row:nth-child(even){animation-name:kvShearB}
  @keyframes kvShearA{to{translate:-14% 0}}
  @keyframes kvShearB{to{translate:14% 0}}

  /* ---- the home showcase band swings level as it crosses the screen */
  .vw-band .vw-tilt{transform:none;animation:kvBand linear both;animation-timeline:view();
    animation-range:cover}
  @keyframes kvBand{0%{rotate:-11deg;scale:.88}48%{rotate:-3deg;scale:1.07}
    100%{rotate:4deg;scale:1.18}}

  /* ---- CARDS: scroll-linked on a desktop, a one-shot arrival everywhere else.
     Measured on the phone hub, CPU throttled 4x, three interleaved runs each:
     live site 12 dropped frames, every card scroll-linked 60, without the
     picture's depth 46, without any per-card scroll animation 21. A scroll
     timeline is cheap per element and not cheap per hundred elements: the hub
     carries 110 cards, two animations each, and every one is ticked as the
     page moves. So a touch device plays the SAME arrival — rise, straighten,
     settle — as a transition when the card reaches the screen, which costs
     nothing once it has played. A desktop gets it tied to the scroll, with
     the picture travelling inside its frame. */
  .cat,.sub{--kv-tilt:5deg;--kv-dx:16px}
  .cat:nth-child(odd),.sub:nth-child(odd){--kv-tilt:-5deg;--kv-dx:-16px}
  .wp-grid>.wp{--kv-tilt:4deg;--kv-dx:12px}
  .wp-grid>.wp:nth-child(odd){--kv-tilt:-4deg;--kv-dx:-12px}
  @keyframes kvArrive{
    from{opacity:0;translate:var(--kv-dx,0px) 96px;scale:.82;rotate:var(--kv-tilt,0deg)}
    to{opacity:1;translate:0 0;scale:1;rotate:0deg}}
  @keyframes kvDepth{from{translate:0 -7%}to{translate:0 7%}}

  /* touch and small screens: the arrival as a transition, played once */
  html:not(.kv-full) .cat .tile-in[data-img-reveal],
  html:not(.kv-full) .sub .tile-in[data-img-reveal],
  html:not(.kv-full) .wp-grid>.wp .wp-frame[data-img-reveal]{
    transform:translate(var(--kv-dx,0px),96px) scale(.82) rotate(var(--kv-tilt,0deg));
    transition:opacity .7s var(--ease),transform 1.05s var(--ease);
    transition-delay:calc(var(--i,0) * .05s)}
  html:not(.kv-full) .cat .tile-in[data-img-reveal].in,
  html:not(.kv-full) .sub .tile-in[data-img-reveal].in,
  html:not(.kv-full) .wp-grid>.wp .wp-frame[data-img-reveal].in{transform:none}

  /* a desktop: tied to the scroll, both ways */
  html.kv-full .cat .tile-in[data-img-reveal],html.kv-full .sub .tile-in[data-img-reveal]{
    opacity:1;transform:none;transition:none}
  html.kv-full .cat .tile-in,html.kv-full .sub .tile-in{animation:kvArrive linear both;
    animation-timeline:view();animation-range:entry 0% cover 32%}
  /* the picture travels inside its frame the whole way through — the frame
     and the image moving at different rates is what reads as depth */
  html.kv-full .tile-in>.plate{inset:-10% 0;height:auto;animation:kvDepth linear both;
    animation-timeline:view();animation-range:cover}
  html.kv-full .wp-grid>.wp .wp-frame[data-img-reveal]{opacity:1;transform:none;transition:none}
  html.kv-full .wp-grid>.wp .wp-frame[data-img-reveal] img{transform:none;transition:none}
  html.kv-full .wp-grid>.wp{animation:kvArrive linear both;animation-timeline:view();
    animation-range:entry 0% cover 30%}
  html.kv-full .wp-frame>.wp-img{position:absolute;left:0;top:-10%;height:120%;
    animation:kvDepth linear both;animation-timeline:view();animation-range:cover}

  /* ---- section headings slide in from the right and keep drifting a little
          as they go, so big type is never simply parked on the page */
  .head .d2,.sc-head .d2{animation:kvSlide linear both;animation-timeline:view();
    animation-range:cover}
  @keyframes kvSlide{0%{translate:8vw 0}45%{translate:0 0}100%{translate:-2vw 0}}

  /* ---- everything else that is a block of content rises into place */
  .grid>.cell,.pils>*,.beat-face,.ab-num,.stat,.kh-fact,.room,.tier,.void,
  .dl-bands,.fg-form{
    animation:kvRise linear both;animation-timeline:view();animation-range:entry 0% cover 24%}
  @keyframes kvRise{from{opacity:0;translate:0 64px;scale:.94}to{opacity:1;translate:0 0;scale:1}}

  /* ---- swipe rows: the card at the centre is the one at full size */
  @media (max-width:999px){
    .rail>*{animation:kvRail linear both;animation-timeline:view(inline);animation-range:cover}
    @keyframes kvRail{0%{scale:.82;opacity:.5}32%,68%{scale:1;opacity:1}
      100%{scale:.82;opacity:.5}}
  }

  /* genuinely constrained devices keep the art and lose the motion */
  html.kv-minimal :is(.hw>.hw-in,.hero-in,.kh-copy,.hw>.vw,.hero-art-wrap,.kh-stage,
    .vw-row,.vw-band .vw-tilt,.cat .tile-in,.sub .tile-in,.tile-in>.plate,.wp-grid>.wp,
    .wp-frame>.wp-img,.head .d2,.sc-head .d2,.grid>.cell,.pils>*,.beat-face,.ab-num,.stat,
    .kh-fact,.room,.tier,.void,.dl-bands,.fg-form,.rail>*){animation:none}
}
}

/* ==========================================================================
   Kiro, touchable (kv.js)
   ========================================================================== */
/* drag and tilt write --kx/--kr and --tx/--ty/--tr into the individual
   translate/rotate properties, so they add to the breathing and the jolt
   (which animate transform) instead of cancelling them */
[data-rig].kiro-touch{
  translate:calc(var(--kx,0px) + var(--tx,0px)) var(--ty,0px);
  rotate:calc(var(--kr,0deg) + var(--tr,0deg));
  transition:transform 1.1s var(--ease),filter .8s var(--ease),
    translate .7s cubic-bezier(.2,1.4,.4,1),rotate .7s cubic-bezier(.2,1.4,.4,1)}
[data-rig].kiro-touch.held{filter:brightness(1.1) saturate(1.15);
  transition:transform 1.1s var(--ease),filter .3s var(--ease),translate .08s linear,rotate .08s linear}

/* the bubble he speaks from carries a button, so it takes taps */
.kiro-say{pointer-events:auto;display:flex;flex-direction:column;gap:10px;align-items:flex-start}
.kiro-surprise{padding:8px 13px;border:1px solid var(--iris);background:var(--iris-dim);
  color:var(--white);font-size:11px;letter-spacing:.16em;text-transform:uppercase;cursor:pointer}
.kiro-surprise:active{transform:scale(.97)}

/* the companion on touch: a small tappable Kiro, bottom right, above the safe
   area. No blurred glow — a filtered layer a phone repaints for nothing — a
   lit ring does the same job. */
.watcher.watcher-touch{display:block;left:auto;right:14px;
  bottom:calc(14px + env(safe-area-inset-bottom,0px));width:60px;
  pointer-events:auto;cursor:pointer;-webkit-tap-highlight-color:transparent}
.watcher-touch .watcher-glow,.watcher-touch .watcher-say{display:none}
.watcher-touch img{-webkit-mask-image:none;mask-image:none;
  box-shadow:0 0 0 1px color-mix(in srgb,var(--kiro-tone,var(--iris)) 70%,transparent),
    0 10px 30px -8px var(--iris-glow)}
.watcher-touch:active .watcher-fig{transform:scale(.92)}
.watcher-touch:focus-visible{outline:2px solid var(--iris);outline-offset:4px;border-radius:50%}

/* ==========================================================================
   The light across a heading (kv.js -> "Headings catch the light")
   The heading's own gradient with one bright stripe laid over it, parked off
   the right edge; .shine carries it across once. Clipped to the glyphs like
   the gradient it rides on, so it is light in the letters, not a bar.
   ========================================================================== */
@media(prefers-reduced-motion:no-preference){
  main .d1.shine,main .d2.shine{
    background-image:
      linear-gradient(104deg,transparent 40%,rgba(255,255,255,.95) 49%,
        rgba(233,213,255,.9) 51%,transparent 60%),
      linear-gradient(174deg,#FFFFFF 6%,#F3EDFF 38%,#C9A0FA 78%,#9B6BE0 100%);
    background-size:260% 100%,100% 100%;
    background-repeat:no-repeat;
    background-position:170% 0,0 0;
    animation:kvShine 1.7s .15s cubic-bezier(.2,.7,.2,1) both}
  @keyframes kvShine{to{background-position:-70% 0,0 0}}
}

/* ==========================================================================
   A tone used as TEXT is lifted until it reads
   A topic's tone is picked as a colour, not as text: 14 of the 63 in use fail
   4.5:1 on the page (Attack on Titan's amber 3.99, the darkest 1.13, i.e.
   invisible), and the same goes for dark text sitting on one. Wherever a tone
   is the text, or the ground under dark text, its OKLCH lightness is raised to
   at least .66 - measured over every hue, that is 5:1 or better on all four
   dark surfaces. Tones already that light are untouched, so most topics look
   exactly as before. It works on the live value of --tone, which kv.js copies
   into the viewer, the gateway and cards at runtime - a build-time colour would
   miss those. Last in the file on purpose: same selectors as the rules it
   corrects, and whichever is written last wins.
   ========================================================================== */
@supports (color: oklch(from red l c h)) {
  .topic .label,.topic .crumb a,.topic .wp-nav a:hover,.topic .chip:hover,
  .topic .btn-ghost:hover,.wp:hover .wp-meta b,.gw-name,.lb-where,.lb-spec a,
  .cat,.sub,.rail-more a:hover{
    color:oklch(from var(--tone,var(--iris)) max(l,.66) c h)}
  .topic .btn-solid,.who.is-on{
    background:oklch(from var(--tone) max(l,.66) c h);
    border-color:oklch(from var(--tone) max(l,.66) c h)}
  .topic .btn-solid::before{background:oklch(from var(--tone2) max(l,.66) c h)}
}
