/* ===========================================================================
   djdusty.org — Deep Field
   ---------------------------------------------------------------------------
   One stylesheet, hand-written, no build step. Tokens first, then components.

   The background is a dark indigo ground, four very large drifting masses,
   and an ordered-dither field over the top. It is neither flat nor still, and
   this paragraph said it was both for two passes after it stopped being true.

   The constraint it was protecting is real and still enforced: someone
   auditioning forty beats is scanning titles, BPM and prices for minutes at a
   time, so the field runs at 0.28 on a catalogue and every colour over it is
   measured, not eyeballed. Run scripts/audit-contrast.py after touching any
   token in this block.
   =========================================================================== */

:root{
  /* A saturated ground, after goodgrowth - deep indigo, and unmistakably a
     COLOUR rather than a dark grey with a cast. Near-black (#0b0c1a) was the
     safe answer for three rounds and was rejected; saturated #140a4a replaced
     it, and this is that colour taken 38% darker at the same hue, which is
     what "a little darker" was asked for. Still a colour, not a retreat.

     THE RULE THIS BLOCK EXISTS FOR: the ground never moves alone. Three
     tokens are defined as positions relative to it, and each one broke once
     by being left behind -
       - a raised surface must be LIGHTER than the ground (--raise was once
         #0d0b26, darker than the ground of the day, so every panel and dialog
         read as a hole cut in the page);
       - a hairline must clear it (--line was once #1f1c45, within a few points
         of the ground's luminance, so every rule and chip border vanished);
       - the masses are a difference FROM it, so they move with it too - see
         the note above the glows.
     So --raise and --line are scaled by the same factor as the ground, and
     the masses by its inverse.

     Every pair is measured against the ground AND against the brightest the
     masses and field reach over it: scripts/audit-contrast.py, figures in
     DESIGN-DECISIONS. */
  --ground:#0f153a;   /* palette 5, exact */
  /* Four masses, all cool, not a warm stop anywhere. goodgrowth runs one
     saturated ground (#4101f5) with soft vignettes over it; this site puts
     some of that saturation in the ground and the rest in the masses, so the
     page reads as coloured rather than as black. (It said "five" and "keeps
     the near-black ground" for two passes; there are four, and the ground has
     not been near-black since the fifth.) */
  /* Pulled back 23% when the ground went darker (sixth pass), which is very
     nearly the 25% the fifth pass added when it went lighter - the same rule
     running backwards. A mass is a difference FROM the ground, so the SAME
     alpha over a darker ground reads as MORE: dropping the ground alone would
     have made the page blotchier, not darker. Ground and masses move together
     or the change is not a change in darkness. */
  /* The opaque field covers these completely, so they are insurance rather
     than decoration - what the page falls back to if the canvas never paints.
     Which is exactly the state a stale `--field` fallback left people in for
     five rounds, so the fallback gets to be cool too. The violet one is now
     a deep blue; nothing here is past indigo. */
  --glow-a:rgba(48,126,192,0.23);   /* palette 7 */
  --glow-b:rgba(25,86,153,0.23);    /* palette 6 */
  --glow-c:rgba(95,63,150,0.21);    /* palette 3 */
  --glow-d:rgba(132,76,173,0.19);   /* palette 2 */
  --raise:#3c2c59;   /* palette 4, exact - a step UP from the ground */
  --line:#bc92cd;    /* palette 1, exact. It was --mute until the field
                        brightened past what it can sit on; a 3:1 rule is
                        fine where 3:1 text would not be. */

  /* THE PALETTE IS BLUE, TEAL AND GREEN. NO VIOLET FAMILY ANYWHERE.

     This was lavender for six rounds - --ink #edeaff, --mute #aeaad6,
     --accent #cdc4ff, --primary #b6a3ff, every one of them hue ~250 - and
     that is what made the site read as purple-pink however cool the
     background got. Dusty said "pinkish" three times while every scan came
     back clean, because the scans were looking for WARM and violet is not
     warm; it is cool and it still reads pink next to a blue. The scheme was
     the problem, not the background.

     Three hues, far enough apart to tell at a glance and all on the cold
     side of the wheel:
       accent  205deg  sky      - links
       c3      188deg  cyan     - focus ring, artist chips
       primary 162deg  aqua     - prices, the buttons that take money

     ALL FIVE WERE LIFTED for the aurora ramp, and that is the trade that
     bought it. The field's brightest stop went 0.022 -> 0.087 so that the
     curtain could actually glow, and with nothing between field and text the
     only way to pay for that is brighter text. --accent binds at 5.17:1.
     --ink and --mute are tinted with the same cold light rather than with
     lavender, so body text belongs to the palette instead of sitting on it. */
  --primary:#e4ccf4; /* lifted: the price buttons sit on the ground directly */
  --accent:#b8dcf8;
  --ink:#f8f0fc;
  --mute:#e4ccec;    /* a paler BC92CD, same hue. The swatch itself is 2.2:1
                        over the brightened field and had to give up being
                        text; it is --line now. 5.36:1 here. */    /* the binding token: it is what caps the ramp. 6.9:1
                        over the brightest stop the field reaches, with
                        nothing in between. Everything else has more room
                        than this does; if one number breaks, it is this
                        one, and scripts/audit-contrast.py says so. */
  --c3:#c4e0fc;

  /* The header bar's tint, as a raw triple so the alpha can vary per stop.
     It is the field's own top stop - the bar is the background blurred, so
     it is tinted with the background. Every theme overrides it. */
  --bar-rgb:15,21,58;

  /* The exclusive tier's outline. Its own token because it is the one border
     on the site that is meant to be noticed: it marks the row that takes the
     beat off the market. It used to be --primary at 45%, which made it the
     same colour as the buy buttons and therefore invisible as a signal. */
  --edge-exclusive:#7ceaff;

  /* The wordmark. White on the dark themes, and the light themes override it
     to their own --ink - a white logo on a pale pink page is not a logo. */
  --logo:#fff;

  /* The label ON a --primary surface: buttons, the cart pill, the kit arrow,
     the skip link. It was hardcoded #0a0820, which was fine while --primary
     was always light - and became dark-on-dark at 1.16:1 the moment a theme
     made --primary dark. A colour that is only correct relative to another
     token has to be a token. scripts/audit-contrast.py checks the pair. */
  --on-primary:#0a0820;   /* focus ring, and the artist-tag chips */

  /* Semantic colour. VIOLET IS NOW THE EXCEPTION HUE, which is the one place
     it earns its keep: with the whole palette on blue-teal-green, a violet is
     the most legible "this is not normal" the scheme can produce without
     going warm. Both of these used to be warm (#ffc861 gold, #ff7f96 pink)
     and warm is the thing that was asked to go.

     The tradeoff is real and worth naming: a yellow warning and a red error
     are conventions people read without thinking, and these are not. Both
     always ship with words next to them - "Staging.", the error text in the
     checkout sheet - so the colour is the second signal and not the only
     one. If that turns out to be the wrong call it is two lines. */
  --ok:#9cedd0;
  --warn:#e6c2f5;
  --bad:#f49cc0;   /* the one swatch NOT from the palette - see note */

  /* One face, three roles, separated by weight and size rather than by
     family. Libre Franklin is a Franklin Gothic in the newspaper tradition:
     formal, plain, and old enough not to read as a trend. The data role used
     to be a monospace and is not any more - BPM, key and tags were hard to
     read at 10px in letterspaced mono, and tabular figures give the columns
     the alignment the mono was there for. */
  --display:'Libre Franklin', 'Helvetica Neue', Helvetica, Arial, sans-serif;
  --data:'Libre Franklin', 'Helvetica Neue', Helvetica, Arial, sans-serif;
  --body:'Libre Franklin', 'Helvetica Neue', Helvetica, Arial, sans-serif;

  /* Five faces ride the band now - beats, loops, sound safe, weirdodustin,
     contact - where there were four, so the step came down and the radius
     went up. A 158px face on a 250px radius subtends 37deg, which clears a
     40deg step; the pair has to move together, because the step alone would
     overlap the names and the radius alone would fling them past the frame. */
  --step:40deg;    /* angle between sections around the globe */
  --radius:250px;  /* how far the band stands off the axis */
  --gut:20px;
  --maxw:1120px;
}

*{box-sizing:border-box}
html{-webkit-text-size-adjust:100%}

/* The UA rule for [hidden] is `display:none` at the lowest specificity there
   is, so ANY class that sets display beats it. Half this stylesheet sets
   display - .stack is display:flex - and the loops page hides one of its two
   views with the attribute alone. Without this the hidden view stays on the
   page under the visible one. Three spot fixes for this already existed
   further down; this is the rule they should have been. */
[hidden]{display:none!important}

body{
  margin:0;
  /* The carried gradient goes HERE, via a custom property, and never on
     `html` - which is where it was, and why the background used to vanish for
     a second after every navigation.

     `html` has no background of its own, so the browser PROPAGATES body's to
     the canvas and then treats body as having none. That propagation is the
     only reason `#field` at z-index -1 is visible at all: with body painting
     nothing, the canvas sits on the root background and the content sits on
     the canvas.

     Setting a background on `html` breaks it. Propagation stops, body keeps
     its own opaque --ground, and body's background is an in-flow block box -
     which paints AFTER negative-z-index children. So the field was being
     covered completely, and only reappeared when field.js cleared the html
     background at the end of the fade. That is the "no background for a
     couple of seconds, then it spawns" exactly.

     A custom property cannot create a background on `html`, so propagation is
     untouched: the gradient becomes body's background, propagates to the
     canvas like --ground always did, and the field paints over it. */
  background:var(--carry, var(--ground));
  color:var(--ink);
  font-family:var(--body);
  font-size:15px;
  line-height:1.6;
  padding-inline:var(--gut);
  padding-block:0 72px;
}

/* Was an animated gradient rule. A hairline does the same job of closing a
   masthead and competes with nothing. */
.aurline{
  height:1px;border:0;margin:0;background:var(--line);
}
/* ===========================================================================
   The background
   ---------------------------------------------------------------------------
   goodgrowth's, in style: a flat ground and a few very large, very soft
   coloured masses. This is the BASE layer; the dither field goes over it,
   further down.

   There was a canvas here once - a perspective plane of points, after
   becksosa - and that one is gone, because Dusty did not want becksosa's
   background. The whole renderer went with it: 290 lines, the adaptive frame
   budget, the accumulated-alpha clamp, the per-point vignette. What survived
   from that round is the palette (cool, no warm stop anywhere) and the fact
   that the background moves at all.

   A canvas came BACK, and it is not that one. The point plane drew sparse
   dots and read as dust; the field below draws an ordered dither, where
   every cell is filled or empty and the surface reads as something printed.
   Same file name, opposite thing - so "there is a canvas" and "the point
   plane is gone" are both true, and this comment has been wrong about that
   once already.

   Three kinds of movement, and they are doing three different jobs:

     drift     each mass translates and swells along its own long path on its
               own clock - 47s, 63s, 83s - so the set never comes back into
               step. Fading in place was an earlier version and it was wrong:
               nothing in a room gets brighter without moving, so a pulse
               reads as an effect where a drift reads as light.
     parallax  the masses answer the pointer, each by a different amount. That
               DIFFERENCE is the parallax - one layer moving is a layer
               following your mouse; three moving by 12, 30 and 58 pixels is
               depth. app.js writes --px and --py; the depths live here.
     easing    a long transition on the parallax transform, so the masses
               arrive a moment after the pointer instead of tracking it. Exact
               tracking reads as a trick; arriving late reads as weight.

   Drift sits on the ::before and parallax on the element, because both are
   transforms and one element cannot hold two. That split is the only reason
   these are real elements rather than pseudo-elements on #bg.
   =========================================================================== */
/* Two things here are load-bearing and neither is obvious.

   ONE: drift uses the independent `translate` property, not `transform`.
   Drift and parallax are both movement, and a single `transform` cannot hold
   two - so the first version put drift on a child ::before and parallax on
   the parent, which is two animated boxes per mass and six composited layers
   for three masses. `translate` and `transform` are separate properties that
   compose onto one element, so each mass is one layer again. Where
   `translate` is unsupported the drift stops and the parallax still works,
   which is the right way round for a fallback.

   TWO: each mass is a BOX THE SIZE OF ITSELF, not a full-bleed layer holding
   a gradient somewhere inside it. That sounds like tidiness and is worth
   about 13fps: a compositor blends the whole of every layer every frame, so
   four full-viewport boxes were painting and blending six viewport-areas a
   frame to show four soft ellipses. Sized to their own ellipses they blend
   about two. Each is `closest-side` so the gradient exactly fills its box,
   which is what makes the box able to BE that size.

   Boxes are generously oversized against their drift, because a mass whose
   edge walks into frame stops being light and becomes a shape.

   No `will-change`. It was measured and made no difference: these are
   animating on compositor properties, so the browser promotes them while they
   move and drops them when they stop - and `will-change` would hold the
   promotion permanently, including on the masses that are only parallaxing
   and are therefore perfectly still until the pointer moves. */
@keyframes driftA{
  0%   {translate:-3% 2%}
  33%  {translate: 5% -4%}
  66%  {translate: 1% 5%}
  100% {translate:-3% 2%}
}
@keyframes driftB{
  0%   {translate: 4% -2%}
  40%  {translate:-5% 4%}
  70%  {translate:-1% -5%}
  100% {translate: 4% -2%}
}
@keyframes driftC{
  0%   {translate: 0% 4%}
  50%  {translate:-5% -5%}
  100% {translate: 0% 4%}
}
@keyframes driftD{
  0%   {translate: 3% 3%}
  50%  {translate:-4% -3%}
  100% {translate: 3% 3%}
}

#bg{
  position:fixed;inset:0;z-index:-2;pointer-events:none;overflow:hidden;
  background:var(--ground);
  --px:0; --py:0;
}
#bg i{
  position:absolute;display:block;
  transform:translate3d(calc(var(--px) * var(--depth)),
                        calc(var(--py) * var(--depth) * .55), 0);
  /* Long, so the masses arrive a moment after the pointer rather than
     tracking it. Exact tracking reads as a trick; arriving late reads as
     weight. */
  transition:transform 1.1s cubic-bezier(.22,.9,.26,1);
}

/* Depth is the only thing that makes this parallax rather than three things
   sliding: the masses move by 12, 20, 34 and 52 pixels, and it is the
   DIFFERENCE between those that reads as distance. Farthest is faintest-
   moving; nearest moves most and is the faintest, because the thing closest
   to you being also the loudest is not how anything looks. */
.m1{                                  /* teal, bottom left */
  --depth:12px;left:-20%;top:44%;width:78%;height:76%;
  background:radial-gradient(closest-side, var(--glow-a), transparent);
  animation:driftA 47s ease-in-out infinite;
}
.m2{                                  /* indigo, bottom right */
  --depth:20px;left:40%;top:50%;width:82%;height:78%;
  background:radial-gradient(closest-side, var(--glow-b), transparent);
  animation:driftB 63s ease-in-out infinite;
}
.m3{                                  /* steel, top right */
  --depth:34px;left:38%;top:-32%;width:80%;height:72%;
  background:radial-gradient(closest-side, var(--glow-c), transparent);
  animation:driftC 83s ease-in-out infinite;
}
.m4{                                  /* violet, upper left */
  --depth:52px;left:-16%;top:-8%;width:64%;height:68%;
  background:radial-gradient(closest-side, var(--glow-d), transparent);
  animation:driftD 71s ease-in-out infinite;
}

@media (prefers-reduced-motion:reduce){
  *,*::before,*::after{animation:none!important;transition:none!important;
    scroll-behavior:auto!important}
  /* app.js never starts the parallax under reduced motion, so --px and --py
     stay at 0 and the masses sit where they were painted. */
}

/* ---------------------------------------------------------------- the field
   goodgrowth's dither, over the masses. field.js draws it; this places it and
   decides how loud it is allowed to be.

   `image-rendering: pixelated` is not a style choice, it is the whole point.
   The buffer is one cell per 3 CSS pixels and the browser would smooth it back
   into a blur on the way up - which would undo the dither entirely and leave a
   soft gradient, i.e. exactly the thing the dither exists instead of.

   FULL STRENGTH, AND THE RAMP IS WHY IT CAN BE. This used to run at 0.28,
   and 0.70 on `body.loud` - the two nearly-empty pages - because the field
   was the only thing between a bright ramp and the text, so legibility could
   only be bought by turning the background down.

   Dimming a spectrum is the wrong lever: at the 0.18 the audit demanded, a
   full-spectrum field is a grey smear, which is the background that has been
   rejected four times. The right lever is the RAMP. Every stop is now under
   0.048 luminance, so the field is legible at full strength and needs
   nothing over it - no scrim, no backdrop filter, no second opacity for
   quiet pages. One number, and it is 1.

   NOT the opening gate. The gate is an overlay on the catalogue at 76%
   ground plus a 4px blur, so the field is behind a veil there regardless.

   There is nothing to lower any more. If this ever moves off 1, the reason
   will be something other than contrast, because contrast is settled in the
   ramp now (scripts/audit-contrast.py). */
#field{
  position:fixed;inset:0;z-index:-1;pointer-events:none;
  width:100%;height:100%;
  image-rendering:pixelated;
  image-rendering:crisp-edges;

  /* IT FADES IN, AND IT STARTS AT ZERO.

     The canvas cannot paint until its deferred script runs, and how long that
     takes is not something this page controls - a slow connection, a cold
     cache, forty assets ahead of it in the queue. Whenever it does arrive it
     used to APPEAR, fully formed, in one frame, which reads as a glitch no
     matter how good the fade after it is.

     Starting at zero and transitioning up means the arrival is always a fade,
     whether it takes 30ms or three seconds. Underneath it is the carried
     gradient the inline script painted - the previous page's ramp, which is
     also where the crossfade begins - so on a fast load the two are the same
     picture and the fade is invisible. On a slow one it is a fade instead of
     a pop. Either way there is no frame where the background jumps.

     If the script never runs at all, the canvas stays at zero and the carried
     gradient or --ground is what you get, which is the right failure. */
  opacity:0;
  transition:opacity .5s ease;
}
#field.lit{opacity:var(--field, 1)}
/* Still, but still there. The texture is worth keeping; the flowing was the
   part that needed asking about. */
/* NO opacity change under reduced motion, and this line is why five rounds of
   ramp edits appeared to do nothing.

   It used to read `opacity:calc(var(--field, 0.28) * .7)`. `--field` is never
   declared as a property anywhere - it only ever existed as the fallback in
   the rule above - so that 0.28 was the value that got used, and it was left
   behind when the field went to full strength. Anyone with "reduce motion"
   turned on was seeing the canvas at 0.196: four fifths of what they were
   looking at was --ground and the drifting masses, which do not change when
   the RAMP changes. The background genuinely did look identical every time.

   There is nothing to fade now. The field is dark by construction rather than
   by opacity, and fading it only ever uncovered the masses underneath. */

/* Was a blurred CSS curtain, then a canvas. Neither survived; the class
   stays because the markup still marks its mastheads with it. */
.sky{position:relative}

@media (prefers-reduced-motion:reduce){
  *,*::before,*::after{animation:none!important;transition:none!important;
    scroll-behavior:auto!important}
}

/* ===========================================================================
   PER-PAGE THEMES
   ---------------------------------------------------------------------------
   Three pages carry one of Dusty's gradient references instead of the site's
   own palette, and all three references are LIGHT. That inverts the text on
   those pages - dark on light - which is why each block redefines the whole
   token set rather than just a colour or two.

   The ramps themselves live in field.js keyed by the same name; this side
   carries the text that has to survive them. scripts/audit-contrast.py checks
   every theme against its own tokens, not against the default pair.

   --bar-rgb goes with them: the header is the background blurred, so a light
   page needs a light bar or it reads as a bruise across the top.

   Semantic colours go dark here for the same reason everything else does. A
   #f49cc0 error on a pink gradient is not an error, it is camouflage.
   =========================================================================== */
body[data-theme="loops"]{
  --ground:#fef6fa;
  --ink:#200424;
  --mute:#301034;
  --accent:#240850;
  --primary:#38082c;
  --c3:#180858;
  --raise:#c49cc8;
  --line:#8c4894;
  --edge-exclusive:#38082c;
  --bar-rgb:254,246,250;
  --ok:#0d4a32;
  --warn:#4a2a08;
  --bad:#6b0f22;
  --logo:#200424;
  --on-primary:#fef6fa;
}

body[data-theme="soundsafe"]{
  /* The new gradient's own top stop, and a panel and a rule mixed from its
     rose and violet rather than from the pink that used to be here. */
  --ground:#f0efed;
  --ink:#241033;
  --mute:#3a1c3f;
  --accent:#2a1058;
  --primary:#4a0f34;
  --c3:#1e1060;
  --raise:#d6b8d8;
  --line:#9a5c9e;
  --edge-exclusive:#4a0f34;
  --bar-rgb:240,239,237;
  --ok:#0d4a32;
  --warn:#4a2a08;
  --bad:#6b0f22;
  --logo:#241033;
  --on-primary:#f0efed;
}

/* SERVICES. A beach - white-ish, sand #ffd194, sea #70e1f5 - so dark text,
   like loops, sound safe and the admin pages. Ink and the prices are a deep
   sea-navy and a burnt orange, so the text belongs to the same shore. */
body[data-theme="services"]{
  --ground:#fffaf2;
  --ink:#0b1f2e;
  --mute:#1d3444;
  --accent:#063a55;
  --primary:#5a2400;
  --c3:#0a2f4a;
  --raise:#fbe6c4;
  --line:#b48a55;
  --edge-exclusive:#5a2400;
  --on-primary:#fffaf2;
  --logo:#0b1f2e;
  --bar-rgb:255,250,242;
  --ok:#0d4a32;
  --warn:#4a2a08;
  --bad:#6b0f22;
}

/* Every admin page - one theme, the dashboard's. Light ramp, so dark text - the same polarity as loops,
   services and weirdodustin.

   The ceiling is 0.0263, which is AA against #28a0bf, the darkest stop after
   the lift. Every value below is under it: the worst is --primary at 5.01:1
   and the rest run 5.05 to 6.04 on that stop, 15.3 to 18.5 against the white
   at the top.

   --accent is the one warm value on a cold ramp, and deliberately: it marks
   the store cross-reference on the upload page, which is the one thing on
   these pages that is a fact about somewhere else. */
body[data-theme="admin"]{
  --ground:#ffffff;
  --ink:#04161c;
  --mute:#0c2630;
  --accent:#3a1014;
  --primary:#06283a;
  --c3:#04202a;
  --raise:#d8f2f7;
  --line:#2c6f86;
  --edge-exclusive:#06283a;
  --on-primary:#d8f2f7;
  --logo:#04161c;
  --bar-rgb:255,255,255;
  --ok:#072a1a;
  --warn:#33200a;
  --bad:#4a0d16;
}

body[data-theme="weirdodustin"]{
  /* Light, so dark text, a pale panel, and a light label on --primary. */
  --ground:#e2fbce;
  --ink:#051b16;
  --mute:#102622;
  --accent:#061e2d;
  --primary:#380a20;
  --c3:#032111;
  --raise:#96dd88;
  --line:#206f60;
  --edge-exclusive:#380a20;
  --on-primary:#8acd7c;
  --logo:#051b16;
  --bar-rgb:226,251,206;
  --ok:#0d4a32;
  --warn:#4a2a08;
  --bad:#6b0f22;
}

/* --------------------------------------------------------------- layout */
.wrap{max-width:var(--maxw);margin:0 auto}
.stack{display:flex;flex-direction:column;gap:36px}

/* NO PANEL, NO SCRIM, NO BACKDROP FILTER. Content sits straight on the
   field, and the field is dark enough to carry it.

   This has now been settled twice in opposite directions, so the reasoning
   matters more than the rule. A bright field cannot have text on it: the
   spectrum that ran up to #2c9e8c put --mute at 1.49:1, so the text needed a
   ground of its own and got one - a scrim per block, feathered at the edges.
   Dusty's answer to that was that he did not want containers and that the
   text should sit in the background the way goodgrowth's does.

   You cannot have both. Either the field is bright and the text needs a
   surface, or the field is dark and the text does not. The ramp went dark
   twice: first to 0.048, which cleared AA at --mute 4.87:1 and which Dusty
   then said was still hard to read - and 4.87 IS hard to read, AA is a floor
   and not a target. It is now under 0.022, holding --mute at 6.61:1 with
   nothing whatsoever between text and background.

   That is the trade, taken deliberately - the background gives up being
   vivid so that it can be the thing the words sit on. What it does not give
   up is being coloured: saturation stays at 0.92 and only lightness is
   spent, which is what keeps this a deep spectrum instead of the grey smear
   that has been rejected four times.

   `--scrim` and `--dim` are gone with it, and so is the feather. There is
   nothing left to feather against. */
main{padding-block:26px 8px}
/* ALWAYS ON SCREEN, pinned to the bottom edge like the header is to the
   top, and faded the same way - the field blurred, rising out of nothing - so
   there is no rule or bar edge anywhere.

   WHEN THE PLAYER IS OPEN IT COVERS THE FOOTER. Both want the bottom edge
   and the player matters more - Dusty's call - so the player sits over it
   and the footer comes back when the player is closed. app.js writes the
   dock's height to --dock-h so the page can scroll clear of it. */
.site-foot{position:fixed;left:0;right:0;bottom:0;z-index:35;
  padding:14px max(var(--gut), calc((100% - var(--maxw)) / 2)) 10px}

/* Room at the end of the page for whatever is pinned over it, so the last
   row can always be scrolled clear of the footer and the player. */
body.has-dock{padding-bottom:calc(var(--dock-h, 88px) + 24px)}
body.has-dock .site-foot{visibility:hidden}
.site-foot::before{
  content:"";position:absolute;inset:0;z-index:-1;pointer-events:none;
  background:linear-gradient(to top,
    rgba(var(--bar-rgb),0.38) 0%,rgba(var(--bar-rgb),0.26) 60%,
    rgba(var(--bar-rgb),0) 100%);
  -webkit-backdrop-filter:blur(12px);backdrop-filter:blur(12px);
  -webkit-mask-image:linear-gradient(to top,#000 55%,transparent 100%);
  mask-image:linear-gradient(to top,#000 55%,transparent 100%);
}

/* --------------------------------------------------------------- header
   STICKY AND FULL BLEED. The bar reaches both screen edges; the words inside
   it stay in the column with everything else.

   That needs two elements, and the reason is worth stating because the
   one-element version was tried first and cannot work: `.site-head` lives
   inside `.wrap`, whose containing block is 1120px wide, so a negative margin
   on it reaches the edge of the COLUMN and stops. `.head-bar` is a child of
   `body` instead, so pulling it out by --gut reaches the edge of the SCREEN.

   It needs something behind it, and that is not a change of mind about
   containers: the field is `position:fixed` so it does not scroll, but the
   page content does, and it would otherwise run straight under the logo and
   the wheel. Two lots of text on top of each other is not a look, it is a bug.

   A gradient, not a bar. Opaque where the text sits, faded to nothing before
   the bar ends, so there is no edge anywhere - content scrolling up dissolves
   into it instead of sliding under a rectangle. That is the same objection
   the content blocks fell to, answered rather than repeated.

   No blur. Blur over a canvas that repaints every frame is a re-blur every
   frame, `.dock` already spends that once, and a gradient is free. Darker
   than --ground on purpose: the top of the ramp is the darkest part of the
   field, so a --ground-toned bar would read as a LIGHTER band on top of it. */
.head-bar{
  position:sticky;top:0;z-index:30;
  margin-inline:calc(var(--gut) * -1);
  padding-inline:var(--gut);
  /* No background of its own - ::before carries it, so the blur can fade out
     without taking the logo and the wheel with it. */
  background:none;
}

/* The bar is the FIELD, BLURRED - not a black slab over it.

   It was rgba(3,1,12,0.94): near-black at 94%, and it read as a black bar
   because that is what it was. This tints with the field's own top stop
   (#0f153a navy) at half that alpha and lets backdrop-filter do the rest, so
   the bar takes its colour FROM the background instead of hiding it.

   WHY A PSEUDO-ELEMENT. backdrop-filter applies to the whole element box and
   cannot fade, so putting it on .head-bar gives a hard edge at the bottom -
   exactly the rectangle the content blocks were removed for. Masking the
   element would fade the logo and the wheel with it. So the filter and the
   mask sit on ::before, behind the content: the surface fades, the text does
   not.

   The field is position:fixed, so this always overlays the SAME part of it -
   the navy top stop, the darkest thing on the page. That is what makes a 52%
   tint enough, rather than a bet on the average case.

   Cost: a blur over a canvas that repaints every frame, which this project
   has been bitten by twice (mask-image 14fps, nested drift layers 13). It is
   one strip rather than the viewport and .dock already spends a blur, but it
   is unmeasured like every frame-rate number here since the browser went. */
.head-bar::before{
  content:"";position:absolute;inset:0;z-index:-1;pointer-events:none;
  background:linear-gradient(to bottom,
    rgba(var(--bar-rgb),0.38) 0%,
    rgba(var(--bar-rgb),0.30) 62%,
    rgba(var(--bar-rgb),0.12) 86%,
    rgba(var(--bar-rgb),0) 100%);
  -webkit-backdrop-filter:blur(16px) saturate(1.15);
  backdrop-filter:blur(16px) saturate(1.15);
  -webkit-mask-image:linear-gradient(to bottom,#000 0%,#000 62%,transparent 100%);
  mask-image:linear-gradient(to bottom,#000 0%,#000 62%,transparent 100%);
}
/* With no backdrop-filter the gradient is the only thing there, so it carries
   more on its own. The enhancement may be missing; the legibility may not. */
@supports not ((backdrop-filter:blur(1px)) or (-webkit-backdrop-filter:blur(1px))){
  .head-bar::before{
    background:linear-gradient(to bottom,
      rgba(var(--bar-rgb),0.74) 0%,
      rgba(var(--bar-rgb),0.66) 62%,
      rgba(var(--bar-rgb),0.22) 86%,
      rgba(var(--bar-rgb),0) 100%);
  }
}
.site-head{
  display:flex;justify-content:space-between;align-items:center;
  gap:18px;flex-wrap:wrap;
  padding-block:22px 26px;
}
.logo{
  font-family:var(--display);font-size:27px;font-weight:700;line-height:1;
  text-decoration:none;padding-bottom:.05em;letter-spacing:-.028em;
  /* White, not --accent. Every other link on the site is sky blue and the
     wordmark is not a link you are meant to consider - it is the masthead.
     It also sits on the darkest part of the field, under the header's own
     gradient, so pure white is the one place on the page that can be pure
     white without costing anything. 16.4:1 over the bar.

     Via a token, because the light themes need their own: white on a pale
     pink page is not a wordmark, it is a gap. */
  color:var(--logo);
}
/* --------------------------------------------------------------- globe */
/* The section selector: the names ride a band around a vertical axis and turn
   horizontally, like longitudes coming round the side of a globe. The server
   writes the current page's index into --index, so the right name is already
   centred before any script runs; app.js adds the turning and the drag.

   Backfaces ARE hidden, which reverses an earlier decision, because the
   earlier decision was only ever safe by accident. Past 90deg a face is
   turned away from you, and sin() is on its way back down - so the far name
   does not keep sliding off the side, it comes back TOWARD the middle,
   mirrored. With four faces the outermost sat at 132deg, far enough out that
   the edge mask mostly covered it; the fifth face put one at 160deg, which
   lands at 41% of the way across the band and reads CONTACT backwards over
   the middle of the header.
   The thing the old comment wanted - a name compressing away rather than
   blinking out - is not lost: a face is down to 9% of its width by 85deg, so
   by the time it is dropped there is nothing of it left to see. */
.wheel{
  --index:0;
  perspective:620px;
  width:min(470px,60vw);height:52px;flex:none;
  position:relative;
  /* no-repeat is load-bearing, not tidiness. A mask repeats by default, and
     the faces are 3D-transformed well outside the wheel's box - so the
     gradient tiled outward and handed full opacity back to exactly the names
     it was there to fade out. "LOOPS" reappeared at the right edge of the
     header, hard-cut by the viewport. Outside a no-repeat mask there is no
     mask value at all, which is alpha zero, and the gradient already reaches
     transparent at both box edges - so the fade stays continuous. */
  -webkit-mask-image:linear-gradient(90deg,transparent,#000 17%,#000 83%,transparent);
  mask-image:linear-gradient(90deg,transparent,#000 17%,#000 83%,transparent);
  -webkit-mask-repeat:no-repeat;mask-repeat:no-repeat;
  -webkit-mask-size:100% 100%;mask-size:100% 100%;
}
/* The equator. One hairline, fading out at the limbs. */
.wheel::before{
  content:"";position:absolute;top:50%;left:0;right:0;height:1px;
  background:linear-gradient(90deg,transparent,var(--line) 30%,
             var(--line) 70%,transparent);
  pointer-events:none;
}
.drum{
  position:absolute;inset:0;transform-style:preserve-3d;
  perspective-origin:50% 50%;
  /* Text selection and the browser's own link-dragging both fight a press
     and drag; neither has any business here. */
  user-select:none;-webkit-user-select:none;
  transform:rotateY(calc(var(--index) * var(--step) * -1));
  /* .62s -> .95s, and an easing that leaves later than it used to. The
     globe is the thing you steer the site with, so it reads better arriving
     than snapping; .22,.9 started fast and then coasted, which made a short
     turn feel abrupt and a long one feel slow. This one eases in as well as
     out, so one name and four names both feel like the same gesture. */
  transition:transform .95s cubic-bezier(.32,.72,.2,1);
  cursor:grab;
}
.drum:active{cursor:grabbing}
.face{
  position:absolute;top:50%;left:50%;
  width:158px;height:34px;margin:-17px 0 0 -79px;
  display:grid;place-items:center;grid-template-areas:"g";
  background:none;border:0;padding:0;cursor:pointer;
  /* A horizontal drag belongs to the globe; a vertical one is the page. */
  touch-action:pan-y;
  transform:rotateY(calc(var(--slot) * var(--step))) translateZ(var(--radius));
  backface-visibility:hidden;-webkit-backface-visibility:hidden;
  font-family:var(--data);font-size:14px;font-weight:600;letter-spacing:.05em;
  text-transform:uppercase;color:var(--mute);text-decoration:none;
  white-space:nowrap;-webkit-user-drag:none;
  /* The field moves underneath, so the names carry a little ground of their
     own - but in the THEME's colour, not in black.

     It was `rgba(3,2,12,.9)` plus a 14px black glow, which is why the names
     looked like they had a shadow stuck to them: on the pale gradients that
     is a black halo around dark text, and even on the dark themes it was
     heavier than the bar behind it. --bar-rgb is the header's own tint, so
     this lifts each name off the dither using the same colour the bar is
     already made of - light on the light pages, dark on the dark ones - and
     at about half the old weight. */
  /* NO SHADOW AT ALL now. Even in the theme's own tint it read as a dark
     drop shadow behind every name, and Dusty did not want it. The bar behind
     the wheel is what keeps the names off the dither. */
}
.face > .fx{grid-area:g}
.face:hover{color:var(--ink)}
/* The page you are on. --ink, not #fff: on a light theme white is the
   background, so the current name was disappearing while its neighbours -
   which use --mute and therefore DO follow the theme - stayed readable. That
   is the "some white, some black" inconsistency. */
.face[aria-current="page"]{color:var(--ink)}
@media (max-width:620px){
  .wheel{width:min(310px,72vw);height:46px;--radius:190px;--step:42deg}
  .face{width:124px;margin-left:-62px;font-size:11px;letter-spacing:.08em}
}
/* Without motion the curvature is only an obstacle. Fall back to a plain row
   - the links are the point, the geometry is decoration. */
@media (prefers-reduced-motion:reduce){
  .wheel{perspective:none;width:auto;height:auto;display:flex;
    -webkit-mask-image:none;mask-image:none}
  .wheel::before{display:none}
  .drum{position:static;transform:none;display:flex;gap:14px;transform-style:flat;
    cursor:auto}
  .face{position:static;transform:none;margin:0;width:auto;height:auto;
    padding:7px 2px;backface-visibility:visible}
}

/* ---------------------------------------------------------------- gate */
/* What the site opens on. Rendered hidden and revealed by the script, so it
   can never trap a reader who has no script to dismiss it with. */
.gate{
  position:fixed;inset:0;z-index:60;display:grid;place-items:center;
  /* THE SKY, OUT OF FOCUS (2026-09-30). Arriving at djdusty.org you should
     not be able to tell what page is behind the globe until you turn it and
     choose. So while the gate is up the page itself is hidden, and only the
     site's own starfield shows, blurred as if a lens were focused past it:
     the waves go soft, a few shooting stars pass as streaks (field.js sends them
     for the gate). Coming in, the sky sharpens back into focus.

     Two rounds went before it. An opaque ground with four drifting colour
     blobs looked generated; a veil with backdrop-filter over the page leaked
     the header and footer text wherever the renderer skipped the blur. Hiding
     the page outright cannot leak. Home page only; a link to a beat or a kit
     goes straight there. */
  background:color-mix(in oklab,var(--ground) 20%,transparent);
}
body.gated .head-bar,body.gated main > *:not(.gate),body.gated .site-foot,
body.gated .dock{visibility:hidden}
/* scale(1.04) pushes the blur's soft, dark edge off the screen. */
body.gated #field{filter:blur(9px) saturate(1.35) brightness(1.15);transform:scale(1.04)}
#field{transition:opacity .5s ease,filter .9s ease,transform .9s ease}
.gate[hidden]{display:none!important}
body.gated{overflow:hidden}
/* No pool of ground behind the text - that read as a dark circle round the
   wheel. Each letter carries its own soft shadow instead, which holds the
   type off the brightest bands without drawing a shape. */
.gate-logo,.gate .face,.gate-hint{
  text-shadow:0 0 14px color-mix(in oklab,var(--ground) 85%,transparent),
              0 0 3px color-mix(in oklab,var(--ground) 60%,transparent)}
.gate-in{
  display:flex;flex-direction:column;align-items:center;gap:30px;
  padding:24px;text-align:center;
}
/* Focus moves here when the overlay opens, so a screen reader is inside the
   overlay rather than still reading the catalogue behind it. The ring is
   suppressed by the tabindex="-1" rule up in the type block, not here. */
.gate-logo{
  font-family:var(--display);font-size:clamp(38px,8vw,72px);font-weight:700;
  letter-spacing:-.03em;line-height:1;
}
.gate .wheel{width:min(640px,92vw);height:82px;--radius:330px;--step:36deg}
.gate .face{width:200px;margin-left:-100px;height:44px;margin-top:-22px;
  font-size:19px;letter-spacing:.04em}
.gate-hint{font-family:var(--data);font-size:12.5px;color:var(--mute);
  letter-spacing:.04em}
@media (max-width:620px){
  .gate .wheel{width:min(330px,88vw);height:66px;--radius:230px;--step:38deg}
  .gate .face{width:145px;margin-left:-72.5px;font-size:15px}
}
@media (prefers-reduced-motion:reduce){
  .gate .wheel{width:auto;height:auto}
  .gate .face{width:auto;margin:0}
}

/* --------------------------------------------------------------- type */
h1,h2,h3{font-family:var(--display);font-weight:700;letter-spacing:-.018em;
  line-height:1.06;margin:0;text-wrap:balance}
h1{font-size:clamp(34px,6vw,58px);padding-bottom:.05em}
h2{font-size:clamp(20px,2.8vw,26px)}
h3{font-size:17px;font-weight:600}
p{margin:0}
.lede{max-width:64ch;color:var(--mute);font-size:15.5px}
.credit-line{font-size:15px;color:var(--mute);margin:0}
.label{font-family:var(--data);font-size:11px;letter-spacing:.11em;
  text-transform:uppercase;font-weight:600;color:var(--mute)}

/* THE QUANTITIES. "3 kits", "8 loops", "32.6 MB", and the live filter count.

   These are the only numbers on the loops page and they were set as --mute at
   the same weight as the word beside them - so "Kits / 3 kits, 8 loops" read
   as one grey caption, and what someone actually wants off that line is how
   much there is.

   Bolded and lifted to --ink, which is a bigger jump than the weight alone:
   at 11px uppercase, weight moves very little and colour moves a lot.

   Wrapped in <b> rather than applied to the whole span, because the kit page
   puts a `terms` LINK on the same line - bolding the span would bold the link
   too and make it look like the emphasis, which is the opposite of true. */
.label b,.kit-meta b,#count{color:var(--ink);font-weight:700}
.label b,.kit-meta b{letter-spacing:inherit}
a{color:var(--accent)}
a:focus-visible,button:focus-visible,input:focus-visible,
summary:focus-visible,[tabindex]:not([tabindex="-1"]):focus-visible{
  outline:2px solid var(--c3);outline-offset:2px;border-radius:2px}

/* tabindex="-1" means "focusable by script, never by Tab". Every one of those
   on this site is a container the script moves focus INTO so a screen reader
   follows the page - the skip target, and the entrance overlay - and none of
   them is a control. A ring on one is a box drawn round something nobody
   pressed; on the entrance it was a cyan box round the logotype, and it was
   the first thing anyone saw. Ruled out at the source rather than patched per
   element, because the next such container would have had the same bug. */
[tabindex="-1"]:focus{outline:none}

/* --------------------------------------------------------------- controls */
.btn{
  font-family:var(--data);font-size:12px;letter-spacing:.12em;
  text-transform:uppercase;
  background:var(--primary);color:var(--on-primary);border:1px solid var(--primary);
  padding:10px 16px;border-radius:2px;cursor:pointer;text-decoration:none;
  display:inline-flex;align-items:center;gap:8px;line-height:1;
}
.btn:hover{filter:brightness(1.12)}
.btn[disabled]{opacity:.45;cursor:not-allowed;filter:none}
.btn-ghost{background:none;color:var(--ink);border-color:var(--line)}
.btn-ghost:hover{border-color:var(--primary);color:var(--accent);filter:none}

/* THE FIELDS SHOW THE FIELD THROUGH THEM. `--raise` is an opaque panel
   colour, and an opaque grey box on a background that is moving is the one
   shape on the page that is plainly pasted on. A translucent fill with a
   blur behind it is the same box with the dither still living underneath it,
   softened - so the search bar belongs to the page it is filtering.

   `backdrop-filter` is not universal and does not need to be: without it the
   colour-mix still resolves to a translucent panel over the field, which is
   the same idea with less finish and no fallback to write. */
/* LIGHTER THAN THE PAGE, NOT A TINT OF IT. `--raise` at 62% let most of the
   gradient through, so a search box over the dark end of a ramp was a dark
   box with pale text in it - readable, but it read as part of the background
   rather than as somewhere to type. At 88% it is a panel the gradient barely
   touches, which is what a field should look like on every page rather than
   on the light ones only. */
input[type="search"],input[type="text"],input[type="email"],
input[type="number"],textarea{
  font-family:var(--body);font-size:14px;
  background:color-mix(in oklab,var(--raise) 88%,transparent);
  backdrop-filter:blur(12px) saturate(1.1);
  -webkit-backdrop-filter:blur(12px) saturate(1.1);
  color:var(--ink);
  border:1px solid var(--line);border-radius:6px;padding:10px 12px;width:100%;
  transition:border-color .16s ease,background .16s ease;
}
input[type="search"]:focus,input[type="text"]:focus,input[type="number"]:focus{
  border-color:color-mix(in oklab,var(--c3) 60%,transparent);
  background:var(--raise);
}

/* ONE HEIGHT FOR EVERYTHING ON THE FILTER ROW. The star sat two pixels short
   of the search box beside it, which is the kind of difference nobody can
   name and everybody sees. Both take the same number now, so they line up
   whatever the font does. */
:root{--ctl-h:44px}
.filters .search input[type="search"]{
  height:var(--ctl-h);padding-block:0;
}
.filters .fav-chip{
  height:var(--ctl-h);display:inline-flex;align-items:center;
  border-radius:6px;padding-inline:14px;
}
input::placeholder,textarea::placeholder{color:var(--mute)}

/* --------------------------------------------------------------- filters */
.filters{display:flex;gap:8px;flex-wrap:wrap;align-items:center}
.filters .search{flex:1 1 220px;min-width:0}
/* A chip carries a value - "140 bpm", "Am", a tag - so it is a small panel
   like the search box beside it, not a hole in the page. It was `none`, which
   over the dark end of a ramp made a row of bpm chips read as unfilled
   outlines. Lighter than the input, because a chip is smaller and a full
   panel at that size reads as a button that has been pressed. */
.chip{
  font-family:var(--data);font-size:12px;font-weight:500;letter-spacing:.03em;
  padding:6px 12px;border:1px solid var(--line);
  border-radius:2px;color:var(--mute);cursor:pointer;line-height:1.2;
  background:color-mix(in oklab,var(--raise) 55%,transparent);
}
.chip:hover{color:var(--ink);border-color:var(--mute)}
.chip[aria-pressed="true"]{
  border-color:color-mix(in oklab,var(--primary) 65%,transparent);
  color:var(--primary)}
.chip.artist[aria-pressed="true"]{
  border-color:color-mix(in oklab,var(--c3) 65%,transparent);color:var(--c3)}

/* Everything currently narrowing the catalogue, named and removable. Tags AND
   together and the tag bar is capped at eighteen chips, so without this a
   filter can be active with nothing on screen accounting for it. */
.activebar{display:flex;gap:8px;flex-wrap:wrap;align-items:center}
.activebar[hidden]{display:none}
.pill{
  font-family:var(--data);font-size:11px;letter-spacing:.06em;
  display:inline-flex;align-items:center;gap:7px;line-height:1.2;
  padding:5px 9px;border-radius:2px;cursor:pointer;
  background:color-mix(in oklab,var(--primary) 16%,transparent);
  border:1px solid color-mix(in oklab,var(--primary) 45%,transparent);
  color:var(--accent);
}
.pill span{font-size:13px;opacity:.75}
a.pill{text-decoration:none}
/* A producer's own site, under their name on their page. */
.producer-site{margin-top:-6px}
.producer-site a{color:var(--accent);text-decoration:none;font-family:var(--data);
  letter-spacing:.04em}
.producer-site a:hover{text-decoration:underline}
.pill:hover{border-color:var(--primary);color:var(--ink)}
.pill.clear{background:none;border-color:var(--line);color:var(--mute);
  text-transform:uppercase;letter-spacing:.1em}
.pill.clear:hover{border-color:var(--mute);color:var(--ink)}

/* The ground the catalogue stands on. Opaque enough that a row of prices is
   never read against moving colour, while the aurora stays visible around it. */
/* Nothing: the catalogue needs no frame, and the background already leaves
   it room. Kept as a hook in case it ever does. */
.panel .beats tbody tr:last-child td{border-bottom:0}

/* --------------------------------------------------------------- catalogue */
.count{font-family:var(--data);font-size:12.5px;color:var(--mute);
  font-variant-numeric:tabular-nums}

/* SEPARATE, NOT COLLAPSED. A collapsed border belongs to the table, not to
   the row, and it is painted before a hovered row - whose blur makes it its
   own layer - so the hover covered the line under it wherever the row edge
   fell on a fraction of a pixel. With separate borders each cell owns the
   line under it and the hovered row paints it on top of its own blur. */
.beats{width:100%;border-collapse:separate;border-spacing:0}
.beats thead th{
  font-family:var(--data);font-size:14px;letter-spacing:.1em;
  text-transform:uppercase;color:var(--ink);text-align:left;font-weight:700;
  padding:15px 8px 10px;border-bottom:1px solid var(--line);white-space:nowrap;
}
.beats th.r,.beats td.r{text-align:right}
.beats tbody td{padding:12px 8px;border-bottom:1px solid var(--line);
  vertical-align:middle}
/* A lift off the ground, not a slab of --raise. On a near-black ground those
   were the same thing; on a saturated one, painting a whole row in the raised
   colour reads as a highlighter rather than as a hover. */
/* A row under the pointer softens the field behind it rather than laying a
   grey over it - the same move as the kit rows and the chosen view. */
.beats tbody tr:hover{
  background:color-mix(in oklab,var(--raise) 52%,transparent);
  backdrop-filter:blur(14px) saturate(1.15);
  -webkit-backdrop-filter:blur(14px) saturate(1.15);
}
/* THE LINE ABOVE A HOVERED ROW BELONGS TO THE ROW ABOVE IT - it is that row's
   border-bottom - and the hover's blur layer is painted over it. At a half-
   pixel row height (every other row, on most screens) it covered the line
   completely. So the hovered row draws its own top line, inside itself, where
   nothing can paint over it. */
.beats tbody tr:hover > td{box-shadow:inset 0 1px 0 var(--line)}
.beats tbody tr[hidden]{display:none}

.cell-title{display:flex;align-items:center;gap:12px;min-width:0}
.cell-title .meta{min-width:0}
.beat-name{font-weight:600;color:var(--ink);text-decoration:none;
  display:block;overflow-wrap:anywhere}
.beat-name:hover{color:var(--accent)}
.tags{display:flex;gap:6px;flex-wrap:wrap;margin-top:3px}
.tag{font-family:var(--data);font-size:13px;font-weight:500;
  letter-spacing:.01em;color:var(--mute);background:none;border:0;
  padding:0;cursor:pointer}
.tag:hover{color:var(--ink)}
.tag.artist{color:var(--accent)}
.credit{font-family:var(--data);font-size:13px;color:var(--mute);
  margin-top:3px}
/* A credit is a link to that producer's page. Underlined only on hover: forty
   rows each carrying two permanently underlined names would make the column
   read as a list of links rather than as a list of beats. */
.credit-link{color:inherit;text-decoration:none}
.credit-link:hover{color:var(--accent);text-decoration:underline}
.credit-line .credit-link{color:var(--ink)}
/* Shown only where the BPM and key columns are dropped - see the responsive
   block. The tempo and key filters stay available at those widths, so the
   fields being filtered on have to stay visible somewhere. */
.rowmeta{display:none;font-family:var(--data);font-size:12px;
  font-weight:500;color:var(--mute);margin-top:3px;
  font-variant-numeric:tabular-nums}

.num{font-family:var(--data);font-size:13.5px;font-weight:500;
  color:var(--mute);font-variant-numeric:tabular-nums;white-space:nowrap}

/* The start button in a loop list. Bigger than the catalogue's, because a
   loop row has no cover art beside it and no title link competing with it -
   the triangle is the first and largest thing in the row, and on a page whose
   whole purpose is auditioning it should be. */
.loops .play{width:40px;height:40px}
.loops .play::after{font-size:16px}
.loops .play[aria-pressed="true"]::after{font-size:14px}

.play{
  width:26px;height:26px;flex:none;border:0;padding:0;cursor:pointer;
  display:grid;place-items:center;background:none;color:var(--ink);font-size:13px;
  line-height:1;
}
.play:hover{color:var(--accent)}
.play[aria-pressed="true"]::after{content:"❙❙";font-size:11px}
.play:not([aria-pressed="true"])::after{content:"▶"}

.buy{display:flex;gap:6px;justify-content:flex-end;flex-wrap:wrap}
.price{
  font-family:var(--data);font-size:12.5px;font-weight:500;
  font-variant-numeric:tabular-nums;
  padding:7px 11px;border:1px solid var(--line);border-radius:2px;
  white-space:nowrap;background:none;color:var(--ink);cursor:pointer;line-height:1.2;
}
.price:hover{border-color:var(--primary);color:var(--accent)}
.price.live{border-color:color-mix(in oklab,var(--primary) 60%,transparent);
  color:var(--primary)}
.price.ask{border-style:dashed;color:var(--mute)}
.price.ask:hover{color:var(--accent)}
/* A button that finishes a sentence (the licensing page's Contact, after
   "cleared with me first"): same button, sized to sit on the text line. */
.price.terms-inline{margin-left:6px;padding:3px 9px;vertical-align:baseline}

/* The exclusive: "Exclusive" at rest, "Send Offer" under the pointer.

   Both labels are always in the markup, stacked in one grid cell, so the cell
   is as wide as the WIDER of them and the swap moves nothing. Animating to a
   longer word would reflow the price column under the pointer, which is the
   one thing a row of prices must not do.

   The hidden label is aria-hidden and the button carries its own aria-label,
   or the accessible name is "Exclusive Send Offer". */
/* THE EXCLUSIVE IS THE ONE THAT SHOULD CATCH THE EYE, and it does it with
   COLOUR ALONE now. It used to breathe - a five second pulse in the border
   and a warm tint washing in and out of the fill - and the pulse is gone.

   NO GLOW, NO BREATH, NO FILL OF ITS OWN. The breath was the last soft-edged
   thing left in a column of hairline buttons, and a price that moves while
   you are reading the row above it is a nag whatever its period. So the
   exclusive now sits on exactly the background mp3 and wav sit on: none. The
   page's own ground shows through all three, and the only thing separating
   the exclusive from its neighbours is the one thing that should - its
   colour.

   VIOLET, NOT CYAN. `--c3` is the focus ring and the chips - the colour of
   "you are here" - and using it for the most expensive control meant the
   exclusive was the same colour as whatever had the keyboard's attention.
   Violet is the scheme's exception hue, reserved for "this is not normal",
   and an exclusive is exactly that: the one purchase that takes a beat off
   the site for everyone else. */
.price.offer{
  border-color:color-mix(in oklab,var(--warn) 45%,transparent);
  color:var(--warn);
  background:none;
}
/* The same hover the other two take, in the exception hue: the border firms
   up and the label brightens. Nothing fills in, so the row does not change
   weight under the pointer. */
.price.offer:hover,.price.offer:focus-visible{
  border-color:var(--warn);color:var(--ink);
  background:none;
}
/* The label swap, for any price button that says one thing at rest and
   what it does under the pointer: the exclusive, and a WAV that has to be
   asked for. */
.price.offer .swap,.price.swaps .swap{display:grid;grid-template-areas:"s";align-items:center;
  justify-items:center}
.price.offer .rest,.price.offer .act,.price.swaps .rest,.price.swaps .act{grid-area:s;
  transition:opacity .18s ease,transform .18s cubic-bezier(.22,1,.36,1)}
.price.offer .act,.price.swaps .act{opacity:0;transform:translateY(4px)}
.price.offer:hover .rest,.price.offer:focus-visible .rest,
.price.swaps:hover .rest,.price.swaps:focus-visible .rest{
  opacity:0;transform:translateY(-4px)}
.price.offer:hover .act,.price.offer:focus-visible .act,
.price.swaps:hover .act,.price.swaps:focus-visible .act{
  opacity:1;transform:none}
/* THE ROW'S BUTTONS HOVER THE WAY THE SERVICE BUTTONS DO: hollow at rest,
   filled with their own colour under the pointer, lifted a pixel, over the
   same 160ms as .price-lg. One button language across the site, so a price
   in a row and a price on a card answer the pointer alike. The exclusive
   fills with its exception hue; --on-primary clears 6.8:1 on --warn in
   every theme. */
.beats .buy .price{
  transition:background 160ms ease,border-color 160ms ease,color 160ms ease,
    transform 160ms ease;
}
.beats .buy .price:hover,.beats .buy .price:focus-visible{
  background:var(--primary);color:var(--on-primary);border-color:var(--primary);
  transform:translateY(-1px);
}
.beats .buy .price.offer:hover,.beats .buy .price.offer:focus-visible{
  background:var(--warn);color:var(--on-primary);border-color:var(--warn);
}
.beats .buy .price:active{transform:translateY(0)}
@media (prefers-reduced-motion:reduce){
  .beats .buy .price{transition:none}
  .beats .buy .price:hover,.beats .buy .price:focus-visible{transform:none}
}
.sold{font-family:var(--data);font-size:10.5px;letter-spacing:.13em;
  text-transform:uppercase;color:var(--mute)}

/* ---- services ----
   Cards rather than rows. A row is for things that differ by a number - a
   tempo, a key, a price - and these differ by what they ARE, so each one gets
   room to say it. */
.services{display:grid;gap:14px;grid-template-columns:repeat(auto-fit,minmax(290px,1fr))}
/* CENTRED. A service card is not a row of data with a price on the end - it
   is one thing being offered, and the whole card is about that one thing, so
   there is no left edge for anything to align to. */
.service{
  display:flex;flex-direction:column;gap:10px;padding:24px 20px;
  text-align:center;align-items:center;
  border:1px solid var(--line);border-radius:6px;
  transition:background 180ms ease,border-color 180ms ease;
  background:color-mix(in oklab,var(--raise) 55%,transparent);
  backdrop-filter:blur(16px) saturate(1.15);
  -webkit-backdrop-filter:blur(16px) saturate(1.15);
}
/* THE CARD HIGHLIGHTS, and that is what the heading's font-swap is tied to.
   A catalogue row has had this since it was built - it raises, and its title
   changes colour and starts changing face - and a service card, which is a
   far bigger target, had no hover state at all. So the animation was firing
   with nothing around it acknowledging the pointer, which reads as a glitch
   rather than as a response.

   `:focus-within` as well as `:hover`, because the card contains buttons and
   arriving by keyboard should light the same lamp. */
.service:hover,.service:focus-within{
  background:color-mix(in oklab,var(--raise) 82%,transparent);
  border-color:color-mix(in oklab,var(--primary) 45%,transparent);
}
.service:hover .service-head h2,.service:focus-within .service-head h2{
  color:var(--accent);
}
.service-head{display:flex;justify-content:center;align-items:baseline;gap:12px}
/* THE CARD HEADINGS ARE FULL h2 SIZE. They were held at 19px on the argument
   that a card heading is smaller than a section heading - but the three cards
   ARE the section, and "Need something else?" underneath them was set at the
   base clamp, so the escape hatch was shouting over the three things actually
   on offer. They all read as one row of headings now. */
.service-head h2{font-size:clamp(20px,2.8vw,26px)}
.service .lede{font-size:14px}
/* No bullets: a centred list with markers on it has its text centred and its
   dots in a ragged column down the middle, which looks like a mistake. */
.service-notes{margin:0;padding:0;list-style:none;display:flex;
  flex-direction:column;gap:7px;max-width:34ch}
.service-notes li{font-size:13px;color:var(--mute)}
.service .buy{margin-top:auto;padding-top:8px;justify-content:center}
/* THE PRICE IS THE POINT OF THE CARD, so it is not the same 12.5px chip that
   sits at the end of a catalogue row among four others. It is the only
   control here and it is sized like it. */
.service .price,.lease-card .price,.kit-head .price{
  font-size:16px;font-weight:600;padding:13px 26px;border-radius:4px;
  border-color:color-mix(in oklab,var(--primary) 55%,transparent);
  color:var(--primary);
}
.service .price:hover,.lease-card .price:hover,.kit-head .price:hover{
  background:var(--primary);color:var(--on-primary);border-color:var(--primary);
}
/* The services heading runs the same letter wave the licence notice does, so
   it needs the same inline-block shell. Scoped, for the `#dock-bar` reason
   above. */
.services-more .wave{display:inline-block;white-space:pre}
/* The card headings run the same hover font-swap the catalogue rows do, so
   they need the same inline-block shell - a .fx span that stays inline gets
   its per-letter layers laid out on the text baseline and the word jitters. */
.service-head h2 .fx{display:inline-block;white-space:pre}
/* The one button on this page that is not a price. Everything around it is a
   12.5px tabular number, so at that size "Send request" reads as a fourth
   service rather than the way out when none of the three fit. Bigger, and
   with room around the words.

   IT WEARS THE SAME BACKGROUND AS THE PRICES NOW: hollow at rest, inverting
   to a solid fill under the cursor. It used to carry a standing 14% tint of
   --primary, on the argument that a tint kept it from reading as a fourth
   service - but SIZE is what says that, and it is still bigger and still set
   in 15px/600 against their 12.5px. A second resting treatment on the one
   card that is not a card was one difference too many: three buttons that
   behave one way and a fourth beside them that behaves another reads as a
   state, not as a different kind of thing. */
.price-lg{
  font-size:15px;font-weight:600;padding:13px 22px;border-radius:3px;
  border-color:var(--primary);
  transition:background 160ms ease,border-color 160ms ease,transform 160ms ease;
}
.price-lg:hover{
  background:var(--primary);color:var(--on-primary);
  border-color:var(--primary);transform:translateY(-1px);
}
.price-lg:active{transform:translateY(0)}
@media (prefers-reduced-motion:reduce){
  .price-lg{transition:none}
  .price-lg:hover{transform:none}
}

/* THE EXCLUSIVE IS AS WIDE AS THE TWO LEASES TOGETHER.

   A grid rather than a second flex row, because that is the only way the
   widths actually agree: `auto auto` sizes the two columns to their own
   content, and the spanning button then measures exactly those two columns
   plus the gap between them. Any flex version is two rows guessing at each
   other's width and drifting apart the moment a price gains a digit.

   `justify-content:end` keeps the whole block right-aligned with every other
   price on the site. */
.buy-grid{
  display:grid;grid-template-columns:auto auto;gap:6px;
  justify-content:end;align-items:start;
}
.buy-grid .buy-span{grid-column:1 / -1}

.service-terms{text-align:center}
/* `.buy` puts prices on the right because that is where a price column
   belongs. A lone button under a heading is not a price column. */
.buy-left{justify-content:flex-start}
/* The terms that apply to all three, once, under the cards rather than
   repeated on each of them. */
/* BIGGER, AND IT WAVES. These two sentences are the only terms a service
   carries and they are easy to scroll past, so they are set in --ink at
   close to body size and run the same letter wave as the licence notice. */
.service-terms{font-family:var(--data);font-size:clamp(14px,1.7vw,17px);
  font-weight:600;color:var(--ink);letter-spacing:.02em;margin:0;
  line-height:1.7}
/* Inline, not inline-block: the sentence has to wrap at its <wbr>s on a
   phone, and an inline-block would make it one unbreakable box. The letters
   inside are still inline-block, which is all the transform needs. */
.service-terms .wave{display:inline;white-space:normal}
/* The one term that costs the buyer something, so it is the one in colour.
   --bad, not --warn: --warn is a pale violet on the dark themes and reads as
   the ink beside it, and this is meant to be seen. */
.sales-final{color:var(--bad);font-weight:700}

.empty{padding:44px 8px;text-align:center;color:var(--mute);
  font-family:var(--data);font-size:12.5px}

/* --------------------------------------------------------------- beat page */
.beat-hero{display:grid;grid-template-columns:minmax(0,220px) minmax(0,1fr);
  gap:28px;align-items:start}
/* height:auto is what makes aspect-ratio apply. The SVG carries width AND
   height attributes, and a presentational height attribute is a used height -
   so `width:100%` narrowed the box to the column while the height stayed at
   the attribute's 600px, leaving a square disc centred in a tall rectangle
   with a border drawn round the empty half. */
/* The big cover on a beat, kit or collection page. SMALLER THAN THE CD WAS,
   on purpose: a circle this plain does not need 260px to say what it is. */
.cover{width:100%;max-width:180px;aspect-ratio:1;display:block}
.spec{display:flex;gap:18px;flex-wrap:wrap;margin-top:14px}
.spec div{display:flex;flex-direction:column;gap:2px}
.spec .v{font-family:var(--data);font-size:15px;color:var(--ink);
  font-variant-numeric:tabular-nums}

.tiers{display:flex;flex-direction:column;gap:10px;margin-top:24px}
.tier{
  display:flex;justify-content:space-between;align-items:center;gap:14px;
  flex-wrap:wrap;padding:14px 16px;border:1px solid var(--line);
  border-radius:3px;background:var(--raise);
}
/* The exclusive tier's outline. `--edge-exclusive` is a real per-theme colour
   in the tokens, and field.js overwrites it about ten times a second with a
   colour sampled from the ramp currently on screen - so the outline drifts
   with the background and travels with it through a page crossfade. With the
   script blocked the token's own value stands. */
.tier.headline{
  border-color:var(--edge-exclusive);
  border-width:1.5px;
  transition:border-color .12s linear;
  /* THE BREATH IS GONE HERE TOO, and for the same reason it went from the
     row's exclusive button: the two were deliberately one effect seen twice,
     so they had to stop being it together. A whole panel washing warm every
     five seconds under someone reading the licence terms printed inside it
     was the loudest thing on the page.

     What is left is the live border - field.js rewrites --edge-exclusive
     about ten times a second from the ramp currently on screen - over the
     same --raise the mp3 and wav tiers sit on. Same background as its
     neighbours, different edge. */
}
.tier .what{display:flex;flex-direction:column;gap:3px;min-width:0}
.tier .what b{font-family:var(--data);font-size:12.5px;letter-spacing:.1em;
  text-transform:uppercase;font-weight:400;color:var(--ink)}
.tier .what span{font-size:12.5px;color:var(--mute)}

/* --------------------------------------------------------------- notices */
/* A notice is the FIELD BLURRED, not a grey box on top of it.

   `background: var(--raise)` made this a flat slab in a colour that appears
   nowhere else on the page - and the loop terms are the one thing on /loops/
   that has to be read before anything is downloaded, so being the dullest
   object on the screen was exactly backwards.

   The tint is `--bar-rgb`, the same token the header bar uses, which is that
   theme's ground. THE CONTRAST IS ALREADY AUDITED and this is why: the ground
   is whichever END of the ramp favours the text - the darkest stop on the dark
   themes, the lightest on the light ones - so compositing it over any stop
   moves the background AWAY from the text's worst case. The bare field is the
   audited bound and this surface is strictly inside it.

   Bigger, too: 14.5px, more padding, a 3px accent edge and a 10px radius, so
   it reads as something addressed to you rather than as fine print. */
.notice{
  border:1px solid var(--line);
  border-left:3px solid var(--accent);
  border-radius:10px;
  padding:18px 20px;
  font-size:14.5px;line-height:1.55;color:var(--mute);
  background:rgba(var(--bar-rgb),0.42);
  -webkit-backdrop-filter:blur(14px) saturate(1.1);
  backdrop-filter:blur(14px) saturate(1.1);
}
/* No filter: the tint alone has to hold it off the dither, so it carries
   more. The enhancement may be missing; the legibility may not. */
@supports not ((backdrop-filter:blur(1px)) or (-webkit-backdrop-filter:blur(1px))){
  .notice{background:rgba(var(--bar-rgb),0.86)}
}
/* THE WAVE, on "Before you download." and nothing else.

   The loop terms are the one thing on /loops/ that has to be read before
   anything is taken, and a blurred panel makes it visible where a grey box
   did not - but visible is not the same as looked at. A wave travelling
   through the opening phrase catches the eye the way a hand does, and then
   stops: it runs once every 5.5 seconds and is still for most of that.

   Per letter, staggered by index. `--i` comes from the markup and the delay
   is computed here, so the phrase can be any length and the stylesheet does
   not need to know. 46ms apart at 3px is a ripple through the words rather
   than eighteen letters bouncing - at a bigger amplitude it reads as a
   cartoon, and this is a licence notice.

   `display:inline-block` is what makes transform apply at all: an inline box
   ignores translateY, so without it the rule is silently inert.

   Off under reduced motion, like everything else that moves on its own. */
.notice .wave{display:inline-block;white-space:pre}
/* NOT SCOPED TO .notice ANY MORE: the same wave now runs on the services
   heading. Only the per-LETTER rule is global - `#dock-bar` also carries
   `.wave` and has no <i> inside it, so this cannot reach the scrubber, while
   the `display:inline-block` block rule below must stay scoped or the
   scrubber stops filling its row. */
.wave i{display:inline-block;font-style:normal;
  animation:term-wave 5.5s ease-in-out infinite;
  animation-delay:calc(var(--i) * 46ms)}
@keyframes term-wave{
  0%,70%,100%{transform:translateY(0)}
  76%{transform:translateY(-3px)}
  82%{transform:translateY(1px)}
  88%{transform:translateY(0)}
}
@media (prefers-reduced-motion:reduce){
  .wave i{animation:none}
}

.notice b{color:var(--ink);font-weight:600}
.notice.warn{border-left-color:var(--warn)}

/* --------------------------------------------------------------- player */
.dock{
  position:fixed;left:0;right:0;bottom:0;z-index:40;
  /* Was 94% opaque over a 10px blur, which is a solid bar with a blur behind
     it doing nothing anybody can see. Half that, over a much deeper blur: the
     field goes on underneath the player instead of stopping at it, and the
     sunset in particular carries straight through the bar. */
  background:color-mix(in oklab,var(--raise) 52%,transparent);
  border-top:1px solid color-mix(in oklab,var(--line) 80%,transparent);
  backdrop-filter:blur(26px) saturate(1.25);
  -webkit-backdrop-filter:blur(26px) saturate(1.25);
  padding:18px var(--gut);
}
.dock[hidden]{display:none!important}
/* THREE COLUMNS, THE OUTER TWO EQUAL, so the waveform is in the middle of
   the bar and not wherever the title's length left it. As wide as the page
   column and no wider: at 1480px it was the only thing on the page that ran
   past the column, and the three groups drifted apart. */
.dock-in{max-width:min(var(--maxw),100%);margin:0 auto;display:grid;align-items:center;
  grid-template-columns:minmax(0,1fr) minmax(0,1.5fr) minmax(0,1fr);gap:18px}
.dock-side{display:flex;align-items:center;gap:16px;min-width:0}
.dock-end{justify-content:flex-end;gap:10px}
.dock-mid{display:flex;align-items:center;gap:12px;min-width:0}
.dock-transport{display:flex;align-items:center;gap:6px;flex:none}
.dock .play{width:50px;height:50px;flex:none;border:1px solid var(--line);
  border-radius:50%;display:inline-grid;place-items:center}
/* THE GLYPHS ARE DRAWN, NOT TYPED. A "▶" from a font sits wherever that
   font's metrics put it - high, or left, or as an emoji - so in a circle it
   never looked centred. A clipped box is exactly where it is told to be, and
   the triangle is nudged right by a sixth of its width, which puts its centre
   of mass (not its bounding box) in the middle of the circle. */
.dock .play::after,.dock .play:not([aria-pressed="true"])::after,
.dock .play[aria-pressed="true"]::after,
.hero-play::after,.hero-play:not([aria-pressed="true"])::after,
.hero-play[aria-pressed="true"]::after{content:"";display:block;
  width:14px;height:16px;background:currentColor}
.dock .play:not([aria-pressed="true"])::after,
.hero-play:not([aria-pressed="true"])::after{
  clip-path:polygon(0 0,100% 50%,0 100%);translate:2px 0}
.dock .play[aria-pressed="true"]::after,.hero-play[aria-pressed="true"]::after{width:13px;
  background:linear-gradient(to right,currentColor 0 34%,transparent 34% 66%,currentColor 66%)}
/* The beat page's play button: the player's circle, a size up, and the
   whole of its job is the one glyph. */
.hero-play{width:56px;height:56px;border:1px solid var(--line);border-radius:50%;
  transition:color .16s ease,border-color .16s ease,background .16s ease}
.hero-play:hover{border-color:var(--primary);
  background:color-mix(in oklab,var(--primary) 14%,transparent)}
.dock .now{min-width:0;flex:1 1 160px}
.dock .now b{display:block;font-size:16px;overflow:hidden;
  text-overflow:ellipsis;white-space:nowrap}
/* Tempo and key under the title. The dock is the one place on the site that
   says what is playing without the row it came from being on screen. */
.dock-meta{display:block;font-family:var(--data);font-size:12px;
  color:var(--mute);margin-top:2px;font-variant-numeric:tabular-nums}
/* And who made it, under that - the row's credit line, links and all. */
.dock-by{display:block;font-family:var(--data);font-size:12px;color:var(--mute);margin-top:1px;
  overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.dock-by:empty{display:none}

/* The waveform. A box rather than a hairline, because it is drawing the
   track and not a percentage - see the note in app.js. */
.dock .wave{flex:1 1 auto;min-width:80px;height:46px;cursor:pointer;
  border-radius:4px}
.dock .wave canvas{display:block;width:100%;height:100%}
.dock-t{flex:none;font-size:12.5px;color:var(--mute);min-width:3.2ch;
  font-variant-numeric:tabular-nums}
#dock-time{text-align:right}
.dock .wave:focus-visible{outline:2px solid var(--c3);outline-offset:3px}

.dock-tools{display:flex;align-items:center;gap:5px;flex:none}
.dock-btn{
  width:40px;height:40px;flex:none;border:0;padding:0;cursor:pointer;
  background:none;color:var(--mute);border-radius:9px;
  display:inline-grid;place-items:center;
  transition:color .16s ease,background .16s ease;
}
.dock-btn:hover{color:var(--ink);background:color-mix(in oklab,var(--ink) 12%,transparent);
  scale:1.06}
/* ON IS A STATE, NOT A SHADE. Shuffle and repeat only changed colour, from
   --mute to --c3, which on a bar of small glyphs is the difference between
   two greys at a glance - people turned shuffle on and could not tell whether
   they had. It is a filled chip now, it keeps a lit ring, and it arrives with
   one pulse so the press is confirmed by movement as well as by colour. */
.dock-btn[aria-pressed="true"]{
  color:var(--c3);
  background:color-mix(in oklab,var(--c3) 17%,transparent);
  box-shadow:inset 0 0 0 1px color-mix(in oklab,var(--c3) 55%,transparent);
  animation:tool-on .5s cubic-bezier(.34,1.56,.64,1);
}
/* The dot under an active toggle, which survives any colour-blindness that
   the accent alone would not. */
.dock-btn[aria-pressed="true"]::after{
  content:"";position:absolute;bottom:4px;left:50%;translate:-50% 0;
  width:4px;height:4px;border-radius:50%;background:var(--c3);
}
.dock-btn{position:relative}
@keyframes tool-on{
  0%{scale:1}
  45%{scale:1.16}
  100%{scale:1}
}
@media (prefers-reduced-motion:reduce){
  .dock-btn[aria-pressed="true"]{animation:none}
  .dock-btn:hover{scale:1}
}
.dock-btn:focus-visible{outline:2px solid var(--c3);outline-offset:2px}
/* THE DOCK'S STAR IS ONE OF THE TOOLBAR'S BUTTONS, not the row's star moved
   here. The row's grows on hover and is --ink at rest; in the bar that made
   it the one button that swelled and the one that was brighter than its
   neighbours. Here it rests, hovers and lights exactly as shuffle does. */
.dock .star-btn{width:40px;height:40px;color:var(--mute)}
.dock .star-btn .star{font-size:19px}
.dock .star-btn:hover,.dock .star-btn[aria-pressed="true"]:hover{transform:none}
.dock .star-btn:hover{color:var(--ink)}
.dock .star-btn[aria-pressed="true"]{color:var(--c3)}

/* VOLUME OPENS UPWARD, and only when it is wanted. `:focus-within` is not
   decoration here: hover alone would put the slider behind a pointer that
   keyboard users do not have. */
/* A HORIZONTAL RANGE, ROTATED. It was a native vertical one - `writing-mode`
   plus `-webkit-appearance:slider-vertical` - and it would not reach the top:
   between the two mechanisms disagreeing and the padding on the track, the
   last part of the travel was simply unreachable, so the loudest the slider
   could be set to was not full volume.

   A horizontal input turned ninety degrees has no such problem. The track
   length is the width, which is one number under our control, the thumb
   travels the whole of it because nothing about it is vertical, and the
   keyboard behaviour stays native. The wrapper is sized for the ROTATED
   result; the input is positioned at its centre and spun about it, so the
   layout box it leaves behind is irrelevant. */
.dock-vol{position:relative;display:flex;align-items:center}
.dock-vol .vol-pop{
  position:absolute;bottom:calc(100% + 10px);left:50%;translate:-50% 0;
  width:40px;height:128px;border-radius:12px;
  background:color-mix(in oklab,var(--raise) 97%,transparent);
  border:1px solid var(--line);
  box-shadow:0 10px 30px rgba(0,0,0,.34);
  opacity:0;visibility:hidden;translate:-50% 6px;
  transition:opacity .18s ease,translate .18s cubic-bezier(.22,1,.36,1),visibility .18s;
}
.dock-vol:hover .vol-pop,
.dock-vol:focus-within .vol-pop{opacity:1;visibility:visible;translate:-50% 0}
.dock-vol input[type=range]{
  position:absolute;top:50%;left:50%;
  width:104px;height:20px;margin:0;padding:0;
  transform:translate(-50%,-50%) rotate(-90deg);
  accent-color:var(--c3);background:none;cursor:pointer;
}
.dock-vol input[type=range]:focus-visible{outline:2px solid var(--c3);outline-offset:4px}
/* Muted: the two arcs come off the speaker rather than the icon changing. */
.dock-btn[aria-pressed="true"] .vol-wave{opacity:.25}

/* THE CART BUTTON IS THE WIDE ONE, and it says the price. Every other
   control in this bar is a toggle whose meaning is its icon; this one costs
   money, so it gets a word next to the glyph and the accent colour, and it
   sits first because it is the only thing here anyone came to do. */
.dock-cart{width:auto;gap:7px;padding:0 12px;color:var(--ink);
  border:1px solid var(--line);font-family:var(--data);font-size:12.5px;
  font-weight:600;display:inline-flex;align-items:center;white-space:nowrap}
.dock-cart:hover{color:var(--on-primary);background:var(--primary);
  border-color:var(--primary)}
.dock-cart[hidden]{display:none}
/* The loop equivalent. Same shape as the cart button so the bar does not
   change size depending on what is playing, and the free tier's own colour. */
.dock-get{width:auto;gap:7px;padding:0 12px;color:var(--ink);
  border:1px solid var(--line);font-family:var(--data);font-size:12.5px;
  font-weight:600;display:inline-flex;align-items:center;white-space:nowrap}
.dock-get:hover{color:var(--on-primary);background:var(--c3);border-color:var(--c3)}
.dock-get[hidden]{display:none}
@media (max-width:640px){.dock-get span{display:none}.dock-get{width:40px;padding:0}}
@media (max-width:640px){.dock-cart #dock-price{display:none}
  .dock-cart{width:40px;padding:0}}

.dock .x{background:none;border:0;color:var(--mute);cursor:pointer;
  font-size:16px;line-height:1;padding:4px}
.dock .x:hover{color:var(--ink)}

/* Narrow: the wave and the tempo line go before the controls do. A player
   you cannot shuffle is worth less than one you cannot see the shape of. */
@media (max-width:980px){
  .dock-in{grid-template-columns:auto minmax(0,1fr) auto;gap:14px}
  .dock .now{max-width:22vw}
}
@media (max-width:720px){
  .dock .wave{height:38px}
  .dock-meta,.dock-by{display:none}
}
@media (max-width:560px){
  .dock .wave,.dock #dock-dur{display:none}
  .dock .now{max-width:none}
  .dock-in{grid-template-columns:minmax(0,1fr) auto auto}
  .dock{padding:10px var(--gut)}
}
@media (max-width:420px){
  .dock #dock-shuffle,.dock #dock-loop{display:none}
}

/* The two links on a delivered line - the audio and its licence. They wrap
   rather than shrink, because "Licence (PDF)" next to "Download" is already
   the widest a tier row gets and a phone has no room to put them side by
   side. */
.tier .get{display:flex;gap:10px;flex-wrap:wrap;align-items:center}
@media (max-width:560px){.tier .get{width:100%}.tier .get .btn{flex:1 1 auto}}

/* --------------------------------------------------------------- footer */
.site-foot{
  display:flex;justify-content:space-between;gap:18px;flex-wrap:wrap;
  font-family:var(--data);font-size:11px;color:var(--mute);
  letter-spacing:.08em;text-transform:uppercase}
.site-foot a,.linkish{color:var(--mute);text-decoration:none}
.site-foot a:hover,.linkish:hover{color:var(--accent)}
.linkish{background:none;border:0;padding:0;font:inherit;letter-spacing:inherit;
  text-transform:inherit;cursor:pointer}

/* CENTRED, and wrapping as a group. With three routes and no heading above
   them the row IS the sheet, and a row of buttons hard against the left edge
   of a box it does not fill reads as the start of a list that got cut off. */
.contact-links{justify-content:center;gap:8px;flex-wrap:wrap}
.contact-request{justify-content:center;padding-top:14px;border-top:1px solid var(--line)}
.contact-links .price{font-size:13px}

/* --------------------------------------------------------------- responsive */
@media (max-width:720px){
  .beats th.opt,.beats td.opt{display:none}
  .rowmeta{display:block}
  .beat-hero{grid-template-columns:1fr;gap:20px}
  .cover{max-width:260px}
}

/* ===========================================================================
   Cart
   ---------------------------------------------------------------------------
   The cart used to be invisible: items accumulated in session storage and a
   click went straight to Stripe, so an abandoned checkout followed by a second
   click charged for both with nothing on screen saying so. It is visible now,
   and nothing leaves for Stripe without the total having been shown first.
   =========================================================================== */

/* THE CART SITS IN THE HEADER, top right, beside the section wheel.
   It was a fixed pill in the bottom corner, which put it over the last row of
   the catalogue and moved itself again whenever the player opened - two
   different places for the same control depending on whether something was
   playing. A cart belongs where a cart belongs.

   ALWAYS PRESENT, NOT ONLY WHEN FULL. The old rule hid it at zero, so the one
   moment someone needs to know where the cart IS - just after adding the
   first thing - was the moment it appeared out of nowhere. It is quiet when
   empty and it announces itself when it fills. */
.head-right{display:flex;align-items:center;gap:14px}
.cart-btn{
  position:relative;display:inline-flex;align-items:center;gap:7px;
  background:none;color:var(--mute);border:1px solid transparent;
  border-radius:10px;padding:7px 9px;cursor:pointer;line-height:1;
  transition:color .18s ease,border-color .18s ease,background .18s ease;
}
.cart-btn:hover{color:var(--ink);border-color:var(--line);
  background:color-mix(in oklab,var(--ink) 7%,transparent)}
.cart-btn:focus-visible{outline:2px solid var(--c3);outline-offset:2px}
.cart-btn .n{
  min-width:19px;height:19px;padding:0 5px;border-radius:10px;
  display:inline-grid;place-items:center;
  font-family:var(--data);font-size:11px;font-variant-numeric:tabular-nums;
  background:var(--line);color:var(--mute);
  transition:background .2s ease,color .2s ease,scale .2s cubic-bezier(.34,1.56,.64,1);
}
/* Full: the count takes the accent, and the whole control takes one bounce so
   the eye is told where the thing it just added went. */
.cart-btn[data-count]:not([data-count="0"]){color:var(--ink)}
.cart-btn[data-count]:not([data-count="0"]) .n{
  background:var(--primary);color:var(--on-primary);
  animation:cart-pop .42s cubic-bezier(.34,1.56,.64,1);
}
@keyframes cart-pop{
  0%{scale:.6}
  60%{scale:1.22}
  100%{scale:1}
}
@media (prefers-reduced-motion:reduce){
  .cart-btn[data-count]:not([data-count="0"]) .n{animation:none}
}

/* The lease chips inside a cart line. Same shape as a price button, because
   that is what they are - the tier, chosen after the fact. */
.leases{display:flex;gap:6px;flex-wrap:wrap;margin-top:5px}
.lease{
  font-family:var(--data);font-size:11px;letter-spacing:.06em;
  padding:4px 8px;border-radius:3px;cursor:pointer;line-height:1;
  border:1px solid var(--line);background:none;color:var(--mute);
  transition:color .16s ease,border-color .16s ease,background .16s ease;
}
.lease:hover{color:var(--ink);border-color:var(--mute)}
.lease[aria-pressed="true"]{
  color:var(--on-primary);background:var(--primary);border-color:var(--primary);
}
.lease:focus-visible{outline:2px solid var(--c3);outline-offset:2px}

dialog.sheet{
  border:1px solid var(--line);border-radius:4px;background:var(--raise);
  color:var(--ink);padding:0;max-width:min(460px,calc(100vw - 32px));width:100%;
  margin:auto;
}
dialog.sheet::backdrop{background:rgba(3,2,12,.72)}
/* THE LEASES, COMPARED - on a beat page and in the sheet the player's cart
   opens, the same cards from the same list (build.py, lease_rows). Built like
   a service card: the name, what it includes, and one price button sized as
   the point of the card. Every card has the same rows in the same order, so
   reading across the three is the comparison. One column on a phone. */
dialog.lease-sheet{max-width:min(920px,calc(100vw - 32px))}
.lease-cards{display:grid;grid-template-columns:repeat(auto-fit,minmax(230px,1fr));gap:14px}
.lease-card{display:flex;flex-direction:column;align-items:center;gap:16px;
  padding:24px 20px;text-align:center;
  border:1px solid var(--line);border-radius:6px;
  background:color-mix(in oklab,var(--raise) 55%,transparent);
  backdrop-filter:blur(16px) saturate(1.15);-webkit-backdrop-filter:blur(16px) saturate(1.15);
  transition:background 180ms ease,border-color 180ms ease}
.lease-card:hover,.lease-card:focus-within{
  background:color-mix(in oklab,var(--raise) 82%,transparent);
  border-color:color-mix(in oklab,var(--primary) 45%,transparent)}
.lease-card.headline{border-color:color-mix(in oklab,var(--edge-exclusive) 70%,transparent)}
.lease-card h3{font-size:clamp(18px,2.2vw,21px);margin:0}
.lease-card:hover h3,.lease-card:focus-within h3{color:var(--accent)}
.feats{list-style:none;margin:0;padding:14px 0 0;display:flex;flex-direction:column;gap:9px;
  font-size:13.5px;line-height:1.35;flex:1;align-self:stretch;text-align:left;
  border-top:1px solid color-mix(in oklab,var(--line) 70%,transparent)}
.feats li{display:grid;grid-template-columns:18px 1fr;gap:8px;align-items:baseline}
.feats li > span[aria-hidden]{text-align:center;font-weight:700;font-size:12px}
.feats li.yes > span[aria-hidden]{color:var(--ok)}
.feats li.no{color:var(--mute)}
.feats li.no > span[aria-hidden]{color:color-mix(in oklab,var(--mute) 80%,transparent)}
.lease-card .buy{justify-content:center;width:100%}
/* The loop terms popup: three rules, each a bold line and its reason. */
.terms-list{margin:0;padding:0 0 0 18px;display:flex;flex-direction:column;gap:12px;
  font-size:14.5px;line-height:1.5}
.terms-list b{color:var(--ink)}
.sheet-in{padding:20px;display:flex;flex-direction:column;gap:16px}
/* The first download of a visit: tick, then the file. */
.terms-agree{display:flex;flex-wrap:wrap;align-items:center;justify-content:space-between;
  gap:12px;padding-top:14px;border-top:1px solid var(--line)}
.terms-agree[hidden]{display:none}
.terms-agree label{display:flex;align-items:center;gap:9px;font-size:14.5px;cursor:pointer}
.terms-agree input{width:17px;height:17px;accent-color:var(--primary)}
.terms-agree button:disabled{opacity:.45;cursor:not-allowed}
.sheet h2{font-size:19px}
.sheet-head{display:flex;justify-content:space-between;align-items:flex-start;gap:14px}
/* A head with no heading in it - just the close button, pushed right, on a
   row that takes no vertical space it does not need. */
.sheet-head-bare{justify-content:flex-end;margin-bottom:-6px}
.sheet-close{background:none;border:0;color:var(--mute);font-size:19px;
  line-height:1;cursor:pointer;padding:2px 4px}
.sheet-close:hover{color:var(--ink)}

.cart-lines{display:flex;flex-direction:column;gap:8px;max-height:46vh;overflow-y:auto}
.cart-line{display:flex;justify-content:space-between;align-items:center;gap:12px;
  padding:11px 12px;border:1px solid var(--line);border-radius:3px}
.cart-line .what{min-width:0}
.cart-line .what b{display:block;font-size:14px;font-weight:600;
  overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.cart-line .what span{font-family:var(--data);font-size:11px;color:var(--mute);
  letter-spacing:.08em;text-transform:uppercase}
.cart-line .right{display:flex;align-items:center;gap:10px;flex:none}
.cart-line .amt{font-family:var(--data);font-size:12.5px;
  font-variant-numeric:tabular-nums}
.cart-line .rm{background:none;border:0;color:var(--mute);cursor:pointer;
  font-size:15px;line-height:1;padding:2px 4px}
.cart-line .rm:hover{color:var(--bad)}

.cart-total{display:flex;justify-content:space-between;align-items:baseline;
  padding-top:12px;border-top:1px solid var(--line);font-family:var(--data);
  font-size:13px;font-variant-numeric:tabular-nums}
.cart-total b{font-size:17px;color:var(--ink);font-weight:400}

/* ---- forms inside sheets ---- */
.field{display:flex;flex-direction:column;gap:5px}
.field label{font-family:var(--data);font-size:10.5px;letter-spacing:.16em;
  text-transform:uppercase;color:var(--mute)}
.field /* A money field: the symbol sits beside the box rather than inside it, where
   the first thing anyone does is select it and type over it. */
.prefixed{display:flex;align-items:stretch;gap:0}
.prefixed > span{
  display:grid;place-items:center;padding:0 12px;
  border:1px solid var(--line);border-right:0;
  border-radius:6px 0 0 6px;
  background:color-mix(in oklab,var(--raise) 62%,transparent);
  font-family:var(--data);font-size:14px;color:var(--mute);
}
.prefixed > input{border-radius:0 6px 6px 0}

.hint{font-size:12px;color:var(--mute)}
.field .err{font-size:12.5px;color:var(--bad);min-height:1em}
.sheet-actions{display:flex;gap:10px;justify-content:flex-end;flex-wrap:wrap}
.sheet-msg{font-size:13.5px;color:var(--mute)}
.sheet-msg.centred{text-align:center;max-width:44ch;margin-inline:auto}
.sheet-msg.good{color:var(--ok)}
.sheet-msg.bad{color:var(--bad)}

/* --------------------------------------------------------------- releases */
.releases{display:flex;flex-direction:column;gap:12px}
.release{
  display:grid;grid-template-columns:104px minmax(0,1fr);gap:16px;
  align-items:center;padding:14px;border:1px solid var(--line);
  border-radius:3px;background:var(--raise);
}
.release-art .cover{width:88px;height:88px}
img.cover{display:block;object-fit:cover;border-radius:6px}
.release-body{display:flex;flex-direction:column;gap:12px;min-width:0}
.release-head{display:flex;align-items:center;gap:12px;min-width:0}
.release-head h2{font-size:18px;margin:0;overflow-wrap:anywhere}
.release-head .label{display:block;margin-top:3px}
.release .buy{justify-content:flex-start}
@media (max-width:520px){
  .release{grid-template-columns:1fr;justify-items:start}
  .release-art .cover{width:104px;height:104px}
}

/* ------------------------------------------------------- catalogue covers */
@keyframes spin{to{transform:rotate(360deg)}}

/* ---- the cover: a gradient circle that turns liquid -----------------------
   covers.py writes one <span class="orb"> per beat, loop or kit, carrying its
   colours as custom properties: --a and --b are its two genre colours, --m
   between them, --ang which way they run, --hx/--hy where the light sits, and
   --d a negative delay so no two covers ever wobble in step.

   AT REST IT IS ALREADY LIQUID, JUST SLOWLY: the colours drift round inside
   it at low strength and the circle breathes a percent or two out of round.
   Both are transform and opacity only, so a page of four hundred covers
   costs the compositor and not the painter. Pointed at, or playing, it goes
   properly liquid - the outline wobbles like a drop, the colours swirl at
   full strength and speed, and the light slides round. Playing is the one
   that matters on a phone, which has no hover.

   All CSS, no filter and no script, so it costs nothing at 30px. */
.orb{position:relative;display:inline-block;flex:none;border-radius:50%;overflow:hidden;
  aspect-ratio:1;
  background:linear-gradient(var(--ang),var(--a),var(--m) 55%,var(--b));
  box-shadow:inset 0 0 0 1px rgba(255,255,255,.06);
  transition:transform .5s cubic-bezier(.3,.7,.2,1);
  animation:breathe 6.5s ease-in-out infinite;animation-delay:var(--d)}
/* the colours inside, which swirl */
.orb::before{content:"";position:absolute;inset:-35%;
  background:
    radial-gradient(38% 38% at 30% 35%,var(--b),transparent 70%),
    radial-gradient(42% 42% at 72% 68%,var(--a),transparent 70%),
    radial-gradient(30% 30% at 70% 25%,var(--m),transparent 70%);
  opacity:.45;transition:opacity .45s ease;
  animation:swirl 16s linear infinite;animation-delay:var(--d)}
/* the light */
.orb::after{content:"";position:absolute;inset:0;border-radius:inherit;
  background:radial-gradient(55% 45% at var(--hx) var(--hy),
    rgba(255,255,255,.38),rgba(255,255,255,0) 70%);
  mix-blend-mode:soft-light}

.orb:hover,
.beats tbody tr:hover .orb,.beats tbody tr.playing .orb,
.kit-row:hover .orb,.beat-hero.playing .orb,.release:hover .orb,.release.playing .orb{
  animation:drop 2.4s ease-in-out infinite;animation-delay:var(--d);transform:scale(1.06)}
.orb:hover::before,
.beats tbody tr:hover .orb::before,.beats tbody tr.playing .orb::before,
.kit-row:hover .orb::before,.beat-hero.playing .orb::before,
.release:hover .orb::before,.release.playing .orb::before{
  opacity:1;animation:swirl 3.6s linear infinite;animation-delay:var(--d)}
.orb:hover::after,
.beats tbody tr:hover .orb::after,.beats tbody tr.playing .orb::after,
.kit-row:hover .orb::after,.beat-hero.playing .orb::after,
.release:hover .orb::after,.release.playing .orb::after{
  animation:glint 2.4s ease-in-out infinite;animation-delay:var(--d)}

@keyframes drop{
  0%,100%{border-radius:50% 50% 50% 50% / 50% 50% 50% 50%}
  20%{border-radius:60% 40% 55% 45% / 43% 58% 42% 57%}
  40%{border-radius:43% 57% 41% 59% / 58% 42% 59% 41%}
  60%{border-radius:56% 44% 60% 40% / 41% 56% 44% 59%}
  80%{border-radius:42% 58% 45% 55% / 57% 43% 56% 44%}
}
/* The resting drift: out of round by a percent or two, never more. */
@keyframes breathe{
  0%,100%{transform:scale(1,1)}
  25%{transform:scale(1.025,.98)}
  50%{transform:scale(.985,1.02)}
  75%{transform:scale(1.015,.99)}
}
/* Still until on screen - app.js puts `live` on the ones in view. A paused
   circle is the same picture as a moving one caught in a frame. */
html.orb-io .orb:not(.live),html.orb-io .orb:not(.live)::before{animation:none}
@media (prefers-reduced-motion:reduce){
  .orb,.orb::before,.orb::after{animation:none!important}
}
/* A row waiting for its batch (app.js, "batches"). */
#rows tr.later{display:none}
@keyframes swirl{to{transform:rotate(360deg)}}
@keyframes glint{
  0%,100%{transform:translate(0,0)} 33%{transform:translate(8%,5%)} 66%{transform:translate(-4%,9%)}
}

/* A circle is a link to everything in its colours. The link adds nothing to
   look at - the circle is already the thing being pointed at. */
.orb-link{display:inline-flex;flex:none;border-radius:50%;line-height:0}
.orb-link:focus-visible{outline:2px solid var(--c3);outline-offset:3px}
.colour-chips{justify-content:center}
.colour-chip{display:inline-flex;align-items:center;gap:8px;text-decoration:none}
.colour-chip[aria-current="page"]{border-color:var(--ink);color:var(--ink)}
.colour-chip .swatch{width:12px;height:12px;border-radius:50%;
  background:hsl(var(--h) var(--s) 56%)}

/* The list thumbnail. Smaller than the CD's 38px, as asked. */
.row-art{width:30px;height:30px}

@media (max-width:420px){ .row-art{width:26px;height:26px} }

/* ------------------------------------------------------- letter swap */
/* Each letter changes face on its own clock - no sweep, no order, so at any
   moment the word is a mix. app.js owns the timing and the list of faces;
   this side owns the geometry and the cross-fade.

   .sz is the only layer in flow. It never changes face, so it holds the
   width steady while .a and .b swap between a gothic, a serif, a mono and a
   condensed underneath it - otherwise every swap would shove the rest of the
   line sideways. */
.fx{display:inline-flex;white-space:pre}
.gl{position:relative;display:inline-block}
.gl i{font-style:normal}
.gl .sz{visibility:hidden}
.gl .a,.gl .b{
  position:absolute;left:50%;top:0;transform:translateX(-50%);
  white-space:pre;transition:opacity .5s ease;
}
/* The breath. Same keyframes on two class names, alternated by app.js on
   every face swap - a CSS animation only restarts when the rule applying it
   changes, and swapping which class carries it does that without the forced
   reflow that remove-then-add would need.

   transform-origin at the baseline so the letter rises off its own foot
   rather than growing from the middle, which reads as a wobble.

   2% and 1px. It has to be felt beside the cross-fade, not seen instead of
   it: the face change is the effect and this is its body. */
@keyframes gl-breath{
  0%{transform:translateY(0) scale(1)}
  38%{transform:translateY(-1px) scale(1.022)}
  100%{transform:translateY(0) scale(1)}
}
.gl.breath-a,.gl.breath-b{
  animation:gl-breath .52s cubic-bezier(.32,.72,.2,1);
  transform-origin:50% 88%;
}
@media (prefers-reduced-motion:reduce){
  .gl.breath-a,.gl.breath-b{animation:none}
}
.gl .b{opacity:0}
.gl.flip .a{opacity:0}
.gl.flip .b{opacity:1}

/* A title split for the hover effect becomes an inline-flex row of letters,
   and .fx is `white-space:pre` so it will not break on its own. Long titles in
   a narrow column would run out of the cell rather than wrap, so these four
   are allowed to. The wordmark is not: it is one short word and wrapping it
   would be worse than overflowing. */
.beat-name .fx,.kit-title .fx,.loop-name .fx,.release-head h2 .fx{flex-wrap:wrap}

/* The word for anything that reads rather than looks. */
.sr{
  position:absolute;width:1px;height:1px;margin:-1px;padding:0;overflow:hidden;
  clip-path:inset(50%);white-space:nowrap;border:0;
}
/* app.js never starts the swapping under reduced motion, so every letter
   stays on the base face; this only removes the fade if one is mid-flight. */
@media (prefers-reduced-motion:reduce){
  .gl .a,.gl .b{transition:none}
}

/* ---------------------------------------------------------- accessibility */
.skip{
  position:absolute;left:-9999px;top:0;z-index:100;
  background:var(--primary);color:var(--on-primary);padding:10px 16px;
  font-family:var(--data);font-size:12px;letter-spacing:.1em;
  text-transform:uppercase;text-decoration:none;border-radius:0 0 2px 0;
}
.skip:focus{left:0}

/* The filter chips are toggles; make the pressed state legible without
   relying on colour alone. */
.chip[aria-pressed="true"]::before{content:"× ";opacity:.8}

/* ===========================================================================
   The wordmark headings
   ---------------------------------------------------------------------------
   The beats page used to lead on the whole tagline. It now says one word, and
   that word runs the same per-letter font clock as the logotype and the
   selector - so the three places the site says its own name all behave the
   same way, and the page has one thing on it rather than a sentence.

   The glyph machinery stacks two absolutely-positioned layers over an
   invisible one that holds the width. At 58px that stack has to be told the
   line box explicitly, or the descender-less measurement of the hidden layer
   leaves the two visible ones sitting proud of it.
   =========================================================================== */
.wordmark{line-height:1;text-transform:lowercase}
.wordmark .fx{align-items:baseline}

/* ===========================================================================
   Notify — the mailing-list offer
   ---------------------------------------------------------------------------
   becksosa puts this at the top of the loops pages as a line of text rather
   than a box, and that is the right shape: it reads as an offer you can walk
   past. Nothing on this site is behind it, so it must not look like a step.
   The dot is the only thing on the page that blinks, which is what earns it
   a glance without a border round it.
   =========================================================================== */
/* THE MAILING-LIST PROMPT.

   It was 12.5px of --mute on no background, which is the same treatment as a
   row's tag line - so the one thing on the page asking for something got read
   as a caption and slid past. It is a button and it now looks like one: a
   real border, real padding, --ink rather than --mute, and 14.5px.

   Still not a panel and still not centred. It sits in the flow where it was,
   because the ask is genuinely minor - it is the only place on the site that
   wants something without giving anything back in the same moment, and a
   banner would be overcharging for an email address. Noticeable, not loud. */
/* CENTRED IN THE COLUMN. The button is one line sitting on its own above the
   page it belongs to, and left-aligned it reads as the first item of a list
   that never arrives. The section centres it; the button itself stays
   inline-flex so the pill is only as wide as its own words. */
.notify{margin-top:10px;display:flex;justify-content:center}
.notify-open{
  /* flex-start, not center: the line wraps on a phone and a centred dot ends
     up beside the second line with nothing to mark. */
  display:inline-flex;align-items:flex-start;gap:11px;
  background:none;cursor:pointer;
  border:1px solid var(--line);border-radius:999px;
  padding:11px 20px 11px 17px;
  font-family:var(--data);font-size:14.5px;font-weight:500;letter-spacing:.01em;
  color:var(--ink);text-align:left;line-height:1.35;
  transition:border-color .18s ease, color .18s ease, transform .18s ease;
}
.notify-open:hover{
  color:var(--ink);
  border-color:var(--accent);
  transform:translateY(-1px);
}
.notify-open:focus-visible{outline:2px solid var(--c3);outline-offset:3px}
.notify-open b{color:var(--ink);font-weight:650;letter-spacing:.005em}

/* A NUDGE, NOT A SHAKE. Every eleven seconds, for half of one.

   A control that moves constantly is a control people learn to ignore, and
   one that moves the moment you arrive is a pop-up with extra steps. This
   waits, moves a little, and waits again - the pattern of something trying to
   catch your eye rather than something demanding it.

   It is 2.5 degrees and 2px. Enough to register in peripheral vision, small
   enough that reading the line while it happens is not a problem - the point
   is to be noticed, and a thing you cannot read is not noticed, it is
   dismissed.

   The animation stops on hover: you have already looked, and a button that
   wobbles under the cursor is a button that is hard to press. */
@keyframes notify-nudge{
  0%, 88%, 100%{transform:translateY(0) rotate(0)}
  90%{transform:translateY(-2px) rotate(-2.5deg)}
  93%{transform:translateY(0) rotate(2deg)}
  96%{transform:translateY(-1px) rotate(-1.2deg)}
  98%{transform:translateY(0) rotate(.5deg)}
}
.notify-open{animation:notify-nudge 11s ease-in-out infinite}
.notify-open:hover,.notify-open:focus-visible{animation-play-state:paused}
@media (prefers-reduced-motion:reduce){
  .notify-open{animation:none}
}
/* The arrow says "this opens something", which the sentence alone did not. */
.notify-open::after{
  content:"\2192";margin-left:2px;opacity:.75;
  transition:transform .18s ease;
}
.notify-open:hover::after{transform:translateX(3px)}
@media (max-width:560px){
  .notify-open{font-size:13.5px;padding:10px 16px 10px 14px}
}
.notify-open .dot{
  width:7px;height:7px;border-radius:50%;flex:none;margin-top:.42em;
  background:var(--c3);box-shadow:0 0 0 0 color-mix(in oklab,var(--c3) 60%,transparent);
  animation:ping 3.2s ease-out infinite;
}
@keyframes ping{
  0%{box-shadow:0 0 0 0 color-mix(in oklab,var(--c3) 55%,transparent)}
  70%,100%{box-shadow:0 0 0 7px transparent}
}
.notify-open:hover .dot{background:var(--accent)}
/* The honeypot. Off the page rather than display:none - a bot that reads the
   computed style skips a hidden field and fills a visible one. */
.hp{position:absolute;left:-9999px;width:1px;height:1px;opacity:0}
#notify-form{display:flex;flex-direction:column;gap:14px}

/* ===========================================================================
   Favourites
   ---------------------------------------------------------------------------
   Per browser, in localStorage, and nowhere else. A list of beats somebody
   likes is not worth an account, an email address or a row in the orders
   database, and asking for one would make the only free convenience on the
   site the one thing that cost something.

   A star, not a heart. It was a heart first, at 15px, in --line - which is
   the colour of the hairline it sits next to, so it read as part of the rule
   rather than as a control nobody had pressed yet. Bigger, brighter, and a
   shape that means "keep this" rather than "like this".
   =========================================================================== */
.star::after{content:"\2606"}                  /* an outline */
/* ONE FACE FOR EVERY STAR. The table's star is in a <button> and took the
   browser's button font (Arial); the filter chip's took the chip's data
   face, whose star is a different drawing. Pinned, so the filter and the
   thing it filters are visibly the same mark. */
.star{font-size:20px;line-height:1;font-style:normal;
  font-family:Arial,Helvetica,sans-serif;font-weight:400}

/* BIGGER, AND IN THE TEXT COLOUR. It was a 36px box holding a 20px star in
   #5b5490 - a hardcoded dim violet that was chosen to recede, and it receded
   so well that people did not find it. It is also one more colour that could
   not follow a theme: on the light gradients a dim violet is neither the ink
   nor the background, just a smudge.

   --ink, which is near-white on the dark themes and dark on the light ones,
   so it reads as "white" where the site is dark without being a literal #fff
   that breaks the moment the page is not. 42px box, 24px star: it is a
   control people look for, not a decoration. */
.star-btn{
  width:42px;height:42px;flex:none;border:0;padding:0;cursor:pointer;
  background:none;color:var(--ink);
  display:inline-grid;place-items:center;
  transition:color .18s ease,transform .18s ease;
}
.star-btn .star{font-size:24px}
.star-btn:hover{color:var(--accent);transform:scale(1.14)}
.star-btn[aria-pressed="true"]{color:var(--c3)}
.star-btn[aria-pressed="true"] .star::after{content:"\2605"}   /* filled */
.star-btn[aria-pressed="true"]:hover{color:var(--c3);transform:scale(1.14)}
.fav-cell{width:1%;white-space:nowrap;padding-left:0!important}

/* The star on a beat page is a labelled button - there is room for a word
   there and no column header to explain it. */
.fav-btn{gap:7px}
.fav-btn .star{font-size:15px}
.fav-btn[aria-pressed="true"]{
  border-color:color-mix(in oklab,var(--c3) 55%,transparent);color:var(--c3)}
.fav-btn[aria-pressed="true"] .star::after{content:"\2605"}
.fav-btn[aria-pressed="true"]:hover{color:var(--c3)}
.hero-actions{display:flex;gap:10px;flex-wrap:wrap;align-items:center;
  margin-top:20px}

/* The saved filter is the same star, filled, rather than the word "saved".
   The control and the thing it collects should be the same mark - reading
   "saved" next to a row of stars makes you work out that they are related. */
.fav-chip{display:inline-flex;align-items:center;gap:7px;padding:5px 11px}
.fav-chip .star{font-size:24px;color:var(--ink)}
.fav-chip:hover .star{color:var(--accent)}
.fav-chip[aria-pressed="true"] .star{color:var(--c3)}
.fav-chip .n{
  min-width:1.1em;text-align:center;padding:1px 6px;border-radius:8px;
  background:color-mix(in oklab,var(--mute) 22%,transparent);
  font-size:10.5px;font-variant-numeric:tabular-nums;color:var(--ink);
}
.fav-chip[aria-pressed="true"]{
  border-color:color-mix(in oklab,var(--c3) 60%,transparent);color:var(--c3)}
.fav-chip[aria-pressed="true"] .star::after{content:"\2605"}
.fav-chip[aria-pressed="true"] .n{
  background:color-mix(in oklab,var(--c3) 26%,transparent);color:var(--ink)}
/* The generic chip toggle mark reads as "remove"; here the filled star and
   the count already say what is on. */
.fav-chip[aria-pressed="true"]::before{content:none}

/* ===========================================================================
   The filter block
   ---------------------------------------------------------------------------
   Tempo and key are always on screen. They used to sit behind `tempo` and
   `key` toggles, which put the two questions people actually arrive with -
   "what have you got around 140?", "anything in F#m?" - one press away from
   being asked at all.
   =========================================================================== */
.filterset{display:flex;gap:12px;align-items:flex-start;flex-wrap:wrap}
.filterset > .label{flex:0 0 46px;padding-top:7px}
.filterset > .filters{flex:1 1 320px;min-width:0}

/* Type a tempo and it finds the closest thing to it. */
.near{position:relative;flex:0 0 auto;display:inline-flex;align-items:center}
/* TYPED TEMPO IS A TEMPO BUTTON THAT YOU FILL IN, so it is sized as one.

   It used to take `--ctl-h` (44px) along with the search box, which is right
   for a search field at the top of the page and wrong here: this sits in the
   Tempo row, inline with the band chips, and at 44px against their 28 it read
   as a different kind of control that had wandered into the row.

   The numbers below are the `.chip` numbers - same font, same padding-block,
   same line-height, same border and radius - so the two match whatever the
   font does rather than by a measurement that drifts. Only the right padding
   differs, to clear the `bpm` unit sitting inside it. */
.near input{
  width:96px;
  font-family:var(--data);font-size:12px;font-weight:500;letter-spacing:.03em;
  line-height:1.2;
  padding:6px 32px 6px 12px;
  border:1px solid var(--line);border-radius:2px;
  font-variant-numeric:tabular-nums;
}
/* The spinners are 12 pixels of arrow on a field whose whole job is three
   digits typed in from the keyboard. */
.near input::-webkit-outer-spin-button,
.near input::-webkit-inner-spin-button{-webkit-appearance:none;margin:0}
.near input[type=number]{-moz-appearance:textfield;appearance:textfield}
/* The placeholder is an example of the format, and at the normal placeholder
   weight "140" sitting in a filter box reads as a filter that is already on. */
.near input::placeholder{color:color-mix(in oklab,var(--mute) 52%,transparent)}
.near .unit{
  position:absolute;right:9px;pointer-events:none;
  font-family:var(--data);font-size:10px;letter-spacing:.06em;color:var(--mute);
}

/* Twelve notes, then the two qualities, on their own line at any width the
   notes do not fill - the quality is a different question and should not read
   as a thirteenth and fourteenth note. */
.notes{display:flex;gap:6px;flex-wrap:wrap}
.quals{display:flex;gap:6px;flex-wrap:wrap;margin-top:8px}
#keybar{display:block}
.chip.note{min-width:40px;justify-content:center;text-align:center;
  font-variant-numeric:tabular-nums}
/* A note with nothing behind it is still shown - a bar that silently drops F#
   is a bar you cannot trust - but it says so. */
.chip.note.bare{opacity:.42}
.chip.qual{letter-spacing:.06em;text-transform:uppercase;font-size:11px}

/* The one at the tempo you actually typed, as opposed to the ones near it. */
.beats tbody tr.exact .num{color:var(--c3)}

/* ===========================================================================
   Loops: the two views
   ===========================================================================*/
.viewswitch{display:flex;gap:10px;align-items:center}
/* THE SAME SIZE AND THE SAME HOVER AS A SERVICE CARD'S PRICE: 16px semibold
   in a 13x26 box, and a full --primary fill under the pointer. They are the
   two biggest decisions on the page, and they were sized like tags. */
.viewswitch .chip{padding:13px 26px;font-size:16px;font-weight:600;
  letter-spacing:.06em;text-transform:uppercase;border-radius:4px;
  transition:background 180ms ease,color 180ms ease,border-color 180ms ease}
/* SELECTED IS A BLUR, NOT A GREY. A flat panel colour behind the chosen view
   is a rectangle that does not belong to the page - the field is moving
   behind everything else and stops dead under this one thing. Blurring what
   is already there keeps the background continuous and still says clearly
   which view you are in, because a blurred moving field and a sharp one are
   never mistaken for each other. */
.viewswitch .chip[aria-pressed="true"]{
  background:color-mix(in oklab,var(--primary) 13%,transparent);
  backdrop-filter:blur(14px) saturate(1.2);
  -webkit-backdrop-filter:blur(14px) saturate(1.2);
  border-color:color-mix(in oklab,var(--primary) 60%,transparent);
  color:var(--accent)}
.viewswitch .chip[aria-pressed="true"]::before{content:none}
.viewswitch .chip:hover,.viewswitch .chip:focus-visible{
  background:var(--primary);color:var(--on-primary);border-color:var(--primary)}
.viewswitch .chip[hidden]{display:none}

.section-head{display:flex;justify-content:space-between;align-items:baseline;
  gap:14px;padding-bottom:10px;border-bottom:1px solid var(--line)}

.crumbs{font-family:var(--data);font-size:11.5px;letter-spacing:.08em;
  text-transform:uppercase;color:var(--mute);display:flex;gap:8px;
  align-items:center}
.crumbs a{color:var(--mute);text-decoration:none}
.crumbs a:hover{color:var(--accent)}

/* ---- the kit list ----
   becksosa's row - number, cover, name, weight - with goodgrowth's hover mark
   on the end: the meta slides left and an accent square opens from the left
   with the arrow already sitting inside it. Both borrowings are on one row and
   it is still a single link, so there is nothing in it a click can miss. */
.kitlist{display:flex;flex-direction:column}
.kit-row{
  display:grid;
  grid-template-columns:auto 72px minmax(0,1fr) auto;
  align-items:center;gap:16px;
  padding:16px 6px;border-bottom:1px solid var(--line);
  text-decoration:none;color:inherit;
}
/* No border-top on the first row: the section head above it already closes
   with a rule, and the two together read as a double line with a gap in it. */
/* The same for a kit under the pointer: the row lifts by softening what is
   behind it rather than by covering it. */
.kit-row:hover,.kit-row:focus-within{
  background:color-mix(in oklab,var(--raise) 55%,transparent);
  backdrop-filter:blur(16px) saturate(1.15);
  -webkit-backdrop-filter:blur(16px) saturate(1.15);
}
.kit-row .num{font-family:var(--data);font-size:12px;color:var(--mute);
  font-variant-numeric:tabular-nums}
.kit-thumb{width:56px;height:56px}
.kit-title{font-family:var(--display);font-size:clamp(19px,2.6vw,26px);
  font-weight:700;letter-spacing:-.018em;line-height:1.1;color:var(--ink);
  overflow-wrap:anywhere}
.kit-row:hover .kit-title{color:var(--accent)}
/* The kit's sound under its name, in the same small type a loop row's tags
   are set in. Not buttons here: the whole row is one link. */
.kit-name{display:flex;flex-direction:column;min-width:0}
.kit-tags .tag{cursor:inherit}
.kit-row .kit-tags .tag:hover{color:var(--mute)}
.kit-head .kit-tags{margin-top:-4px}
.kit-head .price{text-decoration:none}
.kit-right{display:inline-flex;align-items:center;gap:14px;flex:none}
.kit-meta{font-family:var(--data);font-size:11.5px;font-weight:500;
  letter-spacing:.06em;text-transform:uppercase;color:var(--mute);
  white-space:nowrap;font-variant-numeric:tabular-nums;
  transform:translateX(0);transition:transform .42s cubic-bezier(.22,1,.36,1)}
/* The mark opens from nothing to a square. Width is what animates, so the
   arrow inside keeps its own fixed box and is revealed rather than stretched. */
.kit-row .go{
  display:inline-flex;align-items:center;justify-content:center;
  width:0;height:34px;overflow:hidden;flex:0 0 auto;opacity:0;
  background:var(--primary);color:var(--on-primary);border-radius:2px;
  transition:width .24s cubic-bezier(.4,0,.2,1),opacity .08s linear .10s;
}
.kit-row .go svg{flex:0 0 auto;width:34px;height:100%;padding:9px;
  fill:currentColor}
.kit-row:hover .kit-meta,.kit-row:focus-visible .kit-meta{transform:translateX(-10px)}
.kit-row:hover .go,.kit-row:focus-visible .go{
  width:34px;opacity:1;
  transition:width .42s cubic-bezier(.22,1,.36,1),opacity 0s}
@media (max-width:620px){
  .kit-row{grid-template-columns:auto 54px minmax(0,1fr);gap:12px;
    padding:13px 4px}
  .kit-thumb{width:54px;height:54px}
  .kit-right{grid-column:2 / -1;gap:10px}
  .kit-row .go{display:none}
}

/* ---- a kit page ---- */
.kit-head{display:grid;grid-template-columns:minmax(0,260px) minmax(0,1fr);
  gap:30px;align-items:start}
.kit-head .cover{width:100%;max-width:180px}
.beats .kit-num{width:1%;white-space:nowrap;color:var(--mute);
  padding-right:2px!important}
@media (max-width:720px){
  .kit-head{grid-template-columns:1fr;gap:20px}
  .kit-head .cover{max-width:150px}
  .beats th.kit-num,.beats td.kit-num{display:none}
}
.btn-lg{padding:13px 22px;font-size:13px}

/* ===========================================================================
   Sound Safe
   ---------------------------------------------------------------------------
   A section that does not exist yet, which is worth a page anyway: something
   people can see coming beats a surprise, and an empty page that says so is
   honest in a way an absent one is not.
   =========================================================================== */
.soon{display:grid;place-items:center;padding:clamp(48px,14vh,120px) 0;
  text-align:center}
.soon-word{
  font-family:var(--display);font-weight:700;letter-spacing:-.02em;
  font-size:clamp(28px,5vw,48px);color:var(--mute);
  /* It runs the same per-letter font clock as the wordmark now, and .fx is an
     inline-flex row of letters. It must be allowed to wrap on a phone, and it
     has to stay centred inside a grid whose place-items only centres the
     paragraph, not the flex row inside it. */
  display:flex;justify-content:center;
}
.soon-word .fx{flex-wrap:wrap;justify-content:center}

/* ===========================================================================
   Reveal
   ---------------------------------------------------------------------------
   Rows arrive as they come into view. Borrowed from becksosa, and kept small
   on purpose: eight pixels and a fade, not a slide across the screen.

   Gated on html.js, which is set by an inline script before first paint. With
   the script blocked that class is never added, the rule never matches, and
   every row is simply visible - the reveal is decoration, never a condition
   of reading.
   =========================================================================== */
html.js .reveal{opacity:0;transform:translateY(8px)}
html.js .reveal.in{opacity:1;transform:none;
  /* .5s -> .72s. This is the other half of a tab change: the background
     crossfades and the content arrives, and if the content lands first the
     fade is something happening afterwards rather than part of the move. */
  transition:opacity .72s ease,transform .72s cubic-bezier(.22,1,.36,1)}
@media (prefers-reduced-motion:reduce){
  html.js .reveal{opacity:1;transform:none}
}
