/* The hand that writes on the cassette label. Permanent Marker, which this was
   set in before, has no Latvian letters - a, e, i, u, k, l, n, s, z, g with
   their diacritics all fell through to a different face mid-word. Bright Chalk
   carries the lot. See assets/fonts/README.md, including its licence. */
/* Cut down and split in two. The whole face is 1.1 MB - 453 KB even gzipped,
   which was the biggest single thing on the site, bigger than all the artwork
   together - and 92% of that is outlines, because chalk texture takes a lot of
   points. It only ever sets the song title and the artist on the label, so it
   does not need 320 glyphs.

   The first face is what this site actually writes with: ASCII and the eleven
   Latvian letters. The second is the accented Latin-1 letters, and unicode-range
   means it is only fetched if a title ever uses one - so a page of Latvian and
   English pays 127 KB and nothing more.

   Splitting rather than simply dropping them, because the reason this font is
   here at all is that Permanent Marker had no Latvian letters and they fell
   through to a different face mid-word. A subset that is missing a letter brings
   that exact bug back. See assets/fonts/README.md, including its licence, and
   the rebuild command - a redrawn or relicensed font has to be re-subset. */
@font-face {
    font-family: "Bright Chalk";
    src: url("../fonts/BrightChalk.latin.woff2") format("woff2"),
         url("../fonts/BrightChalk.ttf") format("truetype");
    font-weight: 400;
    font-style: normal;
    font-display: swap;
    /* ASCII and NBSP, then Ā Č Ē Ģ Ī Ķ Ļ Ņ Š Ū Ž in both cases.

       U+00A0 belongs here and the range runs to it deliberately. The label's
       two <span>s hold an &nbsp; so they have a height before player.js fills
       them in, and with NBSP left in the latin-ext range below that placeholder
       was enough to pull the whole accented file down on every visit, before a
       word had been written on the tape. It is whitespace; it costs nothing to
       carry here. */
    unicode-range: U+0000-00A0, U+0100-0101, U+010C-010D, U+0112-0113,
                   U+0122-0123, U+012A-012B, U+0136-0137, U+013B-013C,
                   U+0145-0146, U+0160-0161, U+016A-016B, U+017D-017E;
}

@font-face {
    font-family: "Bright Chalk";
    src: url("../fonts/BrightChalk.latin-ext.woff2") format("woff2"),
         url("../fonts/BrightChalk.ttf") format("truetype");
    font-weight: 400;
    font-style: normal;
    font-display: swap;
    unicode-range: U+00A1-00FF;      /* é à ü ñ ö ç and the rest - rarely wanted */
}

/* ------------------------------------------------------------------ *
 * EdvardsMC tape player
 *
 * The cassette shell / reel images come from the Codrops "Old School
 * Cassette Player" demo (tympanus.net/Development/CassettePlayer); the
 * printed label on the shell is masked away in index.php and replaced
 * with the album artwork.
 * ------------------------------------------------------------------ */

:root {
    --ink: #2c3138;
    --ink-soft: #6b7178;
    --ink-faint: #969ba1;
    --line: rgba(30, 45, 70, .35);
    --card: rgba(255, 255, 255, .72);
    --album: #7fb2d6;
    --radius: 12px;

    /* Changing tapes. These three are here so they can be tuned live in the
       browser's inspector: pick the <html> element, find this :root rule in the
       Styles pane, and edit the number. --tape-in-delay is the one that matters
       most - raise it and the new tape waits longer, so the old one is further
       gone before anything else moves. */
    --tape-out: .48s;         /* the tape being lifted off the deck */
    --tape-in: .44s;          /* the one rising to replace it */
    --tape-in-delay: .32s;    /* how long that one waits before it starts */
    /* Turning the tape over. This is **--tape-in-delay + --tape-in**, the length
       of the tape-change animation above, because the two are the same gesture -
       a tape leaving the deck and coming back - and the change is the one that
       already sits right against the sound. Move those two and move this.

       Written out rather than calc()ed: player.js reads it with getPropertyValue
       and a custom property holding a calc() comes back unresolved, so parseFloat
       would give up and take the fallback instead.

       It has been wrong twice. At .5s the tape was back down long before the
       noise finished. Setting it to flip.mp3's full 1.176s fixed the arithmetic
       and made it a dirge - the sound has a long tail and the motion is not meant
       to last it out. Matching the change animation is what actually reads. */
    --tape-flip: .76s;
    --deck-lag: .45s;         /* Play pressed, before the tape actually runs */
    --flip-settle: .6s;       /* quiet after the tape has been turned over */
    --flip-reach: 1.07;       /* how far the tape comes towards you while it turns */
    --flip-rise: 9px;         /* and how far up off the deck it lifts to do it */
    /* How much of the flip is spent lifting the tape out before it starts to
       turn, and the same again putting it back afterwards - so .25 means a
       quarter out, a half turning, a quarter back down. The three happen one
       after another, not together: a hand does not rotate a cassette while it is
       still pulling it out of the deck.

       The 20% and 80% stops in @keyframes tapeTurn are this number. Keyframe
       percentages cannot read a variable, so if this changes they have to be
       moved to match. */
    --flip-lead: .2;

    /* The shelf, desktop only. --rack-overlap is how far each box is pulled up
       over the one before it, as a share of a box's **width**. */
    --rack-overlap: -25%;
    /* How far a box travels out of the shelf - hovered, loaded, and loaded with
       the keyboard on it - as an x,y pair. The y is always x * tan(13.33deg),
       the angle of the fold on the case artwork, so all three sit on one line
       running up and to the right along the case's own edge: hovering and then
       choosing a tape is one continuous travel, not two motions.

       Written as pairs rather than derived from an angle on purpose. The derived
       version worked, but only the state a box was rendered in - a box that
       changed state afterwards kept its old transform, and a hover cannot be
       forced from script to prove otherwise. Pairs are dull and they are right.
       Recompute with y = x * .237 if the artwork's angle ever changes. */
    --box-out-near: 10px, -2.4px;
    --box-out-far: 26px, -6.2px;
    --box-out-most: 34px, -8.1px;

    /* The same idea for the row the rack becomes on a phone, where a case
       rises rather than sliding out - out is where the next box is. The boxes
       overlap by most of their width, so a small rise is nearly invisible:
       14px against a 137px case read as 10% and he could not tell which tape
       was in the deck. The loaded one must always stand highest, so the state
       for one merely being pointed at stays well under it. */
    --box-rise: 28px;
    --box-rise-near: 12px;
    --song-gap: 1s;           /* blank tape between one song and the next */
    /* Seconds a song has to actually run before it counts as played. Not a
       timing - a threshold, so that thumbing through a tape does not make the
       song nobody stayed for the most popular one. See countPlay(). */
    --play-after: 25;
    /* Rew/Fwd: how long the reels run before the song starts. This is a
       landmark inside wind.mp3, not a matter of taste - the sound has a peak
       at 300ms where the deck engages and its loudest at 1030ms where it
       clunks to a stop, and the reels have to stop on that second one. Re-cut
       the sound and re-measure this. See windPause() in player.js. */
    --wind-time: 1s;
    --deck-lag-share: .5;     /* Play: how far into the key's own click the tape starts */

    /* The beat between the reels stopping and the song starting, which is the
       moment the mechanism spends coming out of wind and putting the heads back
       down. --wind-time lands the reels on the clunk in the sound; this is the
       pause after it. The tape-change counterpart is --flip-settle, and the two
       do the same job for the same reason. */
    --wind-settle: 100ms;

    /* Stop: how long the key stays in before it springs back out. Stop is not
       a state on a deck, it is a keypress - it pops Play up and pops straight
       back out itself, leaving nothing pressed. Long enough for the clunk to
       be seen as well as heard, short enough that the finger has left by then. */
    --stop-hold: 180ms;

    /* The cassette's shadow. The four shell PNGs are drawn without one, so it
       is thrown here instead - down and to the right, because the light on this
       artwork comes from the top left, the same direction the reel shadow inside
       the SVG uses. Tunable; it is a drop-shadow, so it follows the silhouette
       and the glass shell casts a lighter one on its own. */
    --tape-shadow: 10px 16px 13px rgba(28, 22, 16, .55);

    /* Crossfading the cassette's artwork in over the drawn stand-in, and how
       long to wait for it before giving up and showing it anyway. The give-up
       is generous on purpose: nothing is blank while it runs - the stand-in
       cassette is already in the deck - so there is no hurry to show a shell
       that has not arrived yet. */
    --art-fade: .45s;
    --art-give-up: 25s;
    /* Under this, a reveal counts as warm: the artwork was already in the cache,
       nothing was ever visibly missing, and there is nothing to cross-fade from.
       See .art-warm below. Raise it and more reloads snap; lower it and more of
       them fade. */
    --art-warm-under: 120ms;

    /* Seconds of the current song that must be buffered past the playhead before
       the next one is fetched in the background. It is a brake, not a timing:
       raise it and the next song is warmed later and more cautiously, drop it to
       0 and the fetch competes with the music somebody is listening to. See
       warmNextTrack() in player.js. */
    --warm-ahead: 20;

    /* The seek bar's three bands - heard, buffered, still to come - and how long
       the buffered one takes to slide out to a new length. The middle one is the
       midpoint of the other two on purpose: close enough to the empty track that
       it never reads as progress, far enough off it to be visible at 5px tall. */
    --seek-played: #7d858e;
    --seek-buffered: #a0a7ae;
    --seek-empty: #c2c6cb;
    --seek-buffer-slide: .3s;

    /* The blank line between two verses on the lyric sheet, and the space a
       heading keeps around itself. One value, so the whole sheet breathes
       together. */
    --lyric-gap: .8em;

    /* The words' own margins. The sheet is only half the inlay and the folded
       flap eats the left of it, so these are tight on purpose - every pixel
       here is a pixel of line length, and a lyric line that wraps loses the
       chords above it. --lyric-clear is measured from the flap's right edge,
       not from the panel, so the two never have to be kept in step by hand. */
    --lyric-pad: 8px;
    --lyric-clear: 6px;

    /* How wide the paper is on a desk. The deck above it is fixed at 600 and
       must stay there - see .player - so the sheet breaks out of that on its
       own. It is what decides whether a line of a song fits: the words get a
       shade over 40% of it once the fold and the running order have taken
       theirs. The cover panel is square, so this is the sheet's height too,
       and a taller sheet simply shows more of the running order. */
    --inlay-w: 700px;

    /* The sheet is exactly this many rows of the running order tall and not a
       row more. It used to be however tall the cover panel was wide, so making
       the paper wider made it taller too and the running order grew empty rows
       at the bottom. Height comes from the list now and width from --inlay-w,
       and the two are independent. */
    --sheet-rows: 10;
    --sheet-h: calc(var(--sides-h) + 2 * var(--songs-pad)
                    + var(--sheet-rows) * var(--song-row));

    /* The folded flap with the album name down it. Fixed, not a percentage of
       the sheet: at 17% a wider sheet meant a fatter red column, which is not
       what widening the paper was for. */
    --spine-w: 51px;

    /* How far the flap is lifted off the sheet's corner, and how tall it is
       lying along the bottom on a phone - one line of the album name between
       9px of padding, and it never grows, because the name is nowrap with an
       ellipsis rather than wrapping.

       Both matter to anything that has to keep clear of it. The lift in
       particular is easy to forget: the flap is translated *up*, so it reaches
       --spine-lift higher than its own height, and words that cleared 40px
       still went under it. Its own construction - the transform and the slope's
       clip-path - writes 9px literally, because that polygon is measured
       geometry and not worth churning. */
    --spine-lift: 9px;
    --spine-h: 40px;

    /* The words' own size, and the one number that decides how long a line of
       a song may be before it breaks in two and loses the chords above it.
       Measured against his own writing, in the column --inlay-w leaves:

           .72rem  53 characters      .84rem  45 characters
           .80rem  48 characters      .92rem  41 characters

       .84 because the longest line he has written is 38, so there are seven
       characters of room, and because .72 was too small to read comfortably.
       A song with longer lines than that wants this dropped a step, not the
       paper widened again. */
    --lyric-size: .84rem;

    /* Where a line that still will not fit continues, so a wrap reads as one
       long line rather than as two short ones. */
    --lyric-hang: 1.1em;

    /* The step down from a heading to the first line under it. It has to beat
       the line spacing inside a verse or the first line looks like it belongs
       to the heading instead of to the verse. */
    --lyric-lead: .55em;

    /* The paper the words are printed on. Translucent, so the cover stays
       visible underneath the way it would through a real inlay - and shared
       with the tab strip above it so the two read as one sheet. */
    --lyric-paper: rgba(246, 242, 233, .87);

    /* What the stand-in cassette is filled with. One per shell, so the card
       that waits in the deck is the colour of the cassette it is waiting for.

       Measured off the shells themselves rather than picked by eye - the plain
       band below the accent stripe, y 620-780, right across the shell where
       there is no sticker, no window and no hole. The glass one is genuinely
       semi-transparent there, at 76%, so it is the only one that lets the page
       through. Tunable: these are the five colours of the artwork, so if a shell
       is ever redrawn, resample rather than guess. */
    --shell-black:  #353435;
    --shell-red:    #c62d2e;
    --shell-orange: #fc872a;
    --shell-white:  #ded6d6;
    --shell-glass:  rgba(50, 50, 51, .76);   /* resampled 2026-09-10, redrawn darker */

    /* Until player.js has said which tape is on. It only ever shows for the
       frame or two before that, so it is a neutral rather than a guess. */
    --shell-ink: #b9bcc0;

    /* The name band on a rack box faces the light, so it carries a wash of white
       over the tape's colour. The artwork panel below it is the same colour flat,
       which read as two different colours meeting at the join. This is that wash,
       in one place, so both wear it. Dial it if the two still do not match. */
    --box-sheen: rgba(255, 255, 255, .08);

    /* The shell of a cassette case, which is semi-transparent white plastic -
       the rim round the top face and round the artwork are both this. Tunable:
       it is the one colour in the rack that is not taken off a cover. */
    --case-shell: #e6e8ea;

    /* The running order. A row is one line on the paper and the list is exactly
       --song-rows of them, so nine songs never bring a scrollbar and the sheet
       is never taller than nine songs need - it was as tall as it was wide on a
       phone, which is room for sixteen. --sides-h is the Side A / Side B tabs
       above the list and --songs-pad the paper's own margin above and below. */
    --song-rows: 9;
    --song-row: 28px;
    --sides-h: 34px;
    --songs-pad: 4px;

    /* Dragging a song up or down the mixtape. --drag-hold is how long a
       finger rests on a song before it lifts (a mouse lifts it at once);
       --drop-line the line drawn where it will land; --drag-edge how close
       to the top or bottom of the list the song has to be carried before the
       list scrolls; --drag-ghost how solid the carried copy is - faint enough
       that the line shows through it, since the copy rides over the line. */
    --drag-hold: 350ms;
    --drop-line: 2px;
    --drag-edge: 24px;
    --drag-ghost: .55;

    /* A crowded shelf. On a phone, past six tapes, each box takes --box-many-w
       of the row and the row scrolls sideways past them. On the desktop shelf,
       past five, the boxes close up instead, so the stack stays inside
       --rack-fit - the room between the top of the shelf and the inlay - with
       never less than --rack-step showing of each box. */
    --box-many-w: 34%;
    --box-shade-room: 14px;      /* under a scrolling row, so the shadows are not cut */
    --tint-ring: 5px;            /* how far the colour chip's dotted ring stands off its swatch */
    --rack-fit: 440px;
    --rack-step: 24px;
    /* how far left of the deck the desktop shelf sits when the window has the
       room - 50px of inlay overhang plus a 34px slide-out; see .rack */
    --rack-clear: 84px;

    /* How thick the cassette is, as a share of its own width. A real one is
       100.5 x 64 x 12 mm, so the true figure is 11.9% - this is a shade under,
       because perspective exaggerates it. */
    --tape-depth: 10cqw;
}

* { box-sizing: border-box; }

html, body { margin: 0; padding: 0; }

/* No blue flash on tap, anywhere.

   A phone paints a translucent blue rectangle over whatever was touched, and it
   is the element's **box** - so on a cassette box, which is a photograph of an
   angled case inside a plain rectangle, the highlight is a blue oblong sitting
   well outside the thing you actually pressed. Same on the song rows and the
   picker chips.

   -webkit-tap-highlight-color is inherited, so it is set once here rather than
   remembered on every new control. It is only the touch flash: :focus-visible
   still draws its outline, so nothing is lost for a keyboard.

   touch-action rides along for a different reason. A phone holds a tap for about
   300ms before it counts, in case a second one follows and means "zoom in" - so
   every control on the page answered a beat late. `manipulation` says
   double-tap-to-zoom is not a gesture here and the tap lands at once. It still
   allows panning and **pinch** zoom, so a page that needs magnifying can still
   be magnified; it is only the double-tap shortcut that goes. */
html {
    -webkit-tap-highlight-color: transparent;
    touch-action: manipulation;
}

/* ---- the artwork arriving ----
   Nothing of the cassette is shown until the whole of it is in. A 1 MB PNG on a
   slow line paints itself a row at a time, and watching that happen is worse
   than not seeing it yet - so the real artwork is one group held at opacity 0
   and a drawn cassette stands in its place until every piece has landed. Then
   the two crossfade, once, and the deck never shows a half-built tape.

   The deck itself is no longer hidden at all. It used to wait, blank, for all of
   this - and, back when the covers went out full size, for 8 MB of them too - and
   a stand-in that is already the right shape and the right colour does that job
   without the wait.

   revealArtwork() in player.js sets .art-in on <html>. The animations are the
   safety net for a dead asset or a script error: the artwork turns up after
   --art-give-up either way. */
.tapeReal {
    opacity: 0;
    transition: opacity var(--art-fade) ease;
    animation: art-give-up var(--art-fade) ease var(--art-give-up) forwards;
}

.tapeBlank {
    transition: opacity var(--art-fade) ease;
    animation: art-blank-give-up var(--art-fade) ease var(--art-give-up) forwards;
}

.art-in .tapeReal { opacity: 1; animation: none; }

/* visibility, not just opacity: once the artwork is in, the stand-in has to be
   gone rather than transparent, so nothing - a clone, a stacking context, a
   browser rounding an opacity - can put an edge of it back behind the cassette.
   The delay holds it until the crossfade has finished. */
.art-in .tapeBlank {
    opacity: 0;
    visibility: hidden;
    transition: opacity var(--art-fade) ease,
                visibility 0s linear var(--art-fade);
    animation: none;
}

/* A reveal that cost nothing does not get a crossfade.

   revealArtwork() times itself: if every piece answered within
   --art-warm-under, it all came out of the cache, the stand-in was never on
   screen long enough to be seen, and fading it out is the page flickering on
   every single reload for no reason at all. So the swap is instant instead -
   the same treatment prefers-reduced-motion gets, for the same reason, which is
   that there is nothing worth animating.

   Only the cassette. A cover that genuinely changes later still fades in, and
   that one is worth keeping: it is a different picture arriving, not the same
   one being put back. */
.art-warm .tapeReal,
.art-warm .tapeBlank { transition: none; }

/* player.js puts the shell's name on <html>; the stand-in reads its colour from
   here. An attribute rather than five classes, so there is nothing to remove
   when the tape changes - and :root, because the cassette picker's own chips
   carry a data-shell too and these are not for them. */
:root[data-shell="cassette_black"]  { --shell-ink: var(--shell-black); }
:root[data-shell="cassette_red"]    { --shell-ink: var(--shell-red); }
:root[data-shell="cassette_orange"] { --shell-ink: var(--shell-orange); }
:root[data-shell="cassette_white"]  { --shell-ink: var(--shell-white); }
:root[data-shell="cassette_glass"]  { --shell-ink: var(--shell-glass); }

@keyframes art-give-up       { to { opacity: 1; } }
@keyframes art-blank-give-up { to { opacity: 0; } }

@media (prefers-reduced-motion: reduce) {
    .tapeReal,
    .tapeBlank { transition: none; }
}

body {
    min-height: 100vh;
    padding: 0 16px 56px;
    background:
        radial-gradient(1100px 700px at 50% 34%, #fdfdfe 0%, #eceef0 42%, #cfd2d6 100%),
        #d3d6da;
    background-attachment: fixed;
    color: var(--ink);
    font-family: "Helvetica Neue", Helvetica, Arial, sans-serif;
    -webkit-font-smoothing: antialiased;
}

/* ------------------------------------------------------------------ *
 * masthead
 * ------------------------------------------------------------------ */

.masthead {
    text-align: center;
    padding: 22px 0 16px;
}

.masthead__name {
    margin: 0;
    font-family: Oswald, "Helvetica Neue", Arial, sans-serif;
    font-weight: 600;
    font-size: clamp(2.3rem, 8.5vw, 3.9rem);
    letter-spacing: .02em;
    line-height: 1;
    color: #383d44;
    text-shadow: 0 1px 0 #fff, 0 2px 3px rgba(0, 0, 0, .12);
}

.masthead__tagline {
    margin: 9px 0 0;
    font-family: Oswald, "Helvetica Neue", Arial, sans-serif;
    font-weight: 400;
    font-size: clamp(.75rem, 2.4vw, .95rem);
    letter-spacing: .34em;
    text-transform: uppercase;
    color: var(--ink-faint);
    text-shadow: 0 1px 0 rgba(255, 255, 255, .8);
}

/* ------------------------------------------------------------------ *
 * layout - the player is centred on the page and the rack hangs off to
 * the side without taking any width away from it
 * ------------------------------------------------------------------ */

.deck {
    position: relative;
    display: flex;
    flex-direction: column;
    width: 100%;
    /* Fixed. The inlay is half this width and its height is that same square,
       so anything that scales the deck scales the inlay with it - which is not
       allowed. Height has to come out of the furniture instead. */
    max-width: 600px;
    margin: 0 auto;
}

.player { order: 1; min-width: 0; }
.rack { order: 2; }
.rack__pull { display: none; }
.deck--empty .rack { display: none; }

@media (min-width: 1041px) {
    /* The width and the gap come out of the same space, and there is less of it
       than it looks. The deck is 600px and centred, so at this breakpoint the
       room beside it is (1041 - 600) / 2 - and a vertical scrollbar takes 15 of
       that first, leaving **213px**. width + margin has to stay inside 213 or
       the shelf runs off the left edge at exactly the width where it appears.

       Measured, not assumed: the old 172 + 42 came to 214 and was already a
       pixel over. 190 + 22 is 212, so the boxes are 10% bigger than they were
       and the shelf fits where it did not before. Widening one means narrowing
       the other, or moving the breakpoint with them.

       The cover srcset in index.php names this width too: `sizes` says how wide
       the slot is, and getting it wrong here makes the browser pick a copy that
       is too small and draw it soft. */
    .rack {
        position: absolute;
        top: 32px;
        right: 100%;
        width: 190px;
        /* As far left as the window allows, up to --rack-clear. The inlay is
           wider than the deck by 50px a side, and a chosen box slides out
           34px (--box-out-most), so at 84px the shelf clears the paper even
           with a box pulled - Edvards asked for the chosen tape not to lie
           over the inlay. The 22px floor is the figure worked out above, for
           the window width where the shelf first appears; the middle term is
           whatever is left beside the deck past the shelf and the scrollbar. */
        margin-right: clamp(22px, calc((100vw - 600px) / 2 - 205px), var(--rack-clear));
    }
}

/* ------------------------------------------------------------------ *
 * the rack of cassettes
 * ------------------------------------------------------------------ */

.rack__stack {
    display: flex;
    flex-direction: column;
    padding-bottom: 8px;
}

/* ---- a cassette box, seen from slightly above ----
   Two faces meeting at an edge: the top of the box, an isosceles trapezoid
   narrowing towards its far side, with the name printed along it; and the face
   below carrying the artwork behind the plastic. The trapezoid is what does the
   work - a parallel-sided band would just read as a coloured stripe.

   Drop a picture of a real case in as assets/img/case.png and index.php sets
   --case, which replaces the drawn plastic and leaves the rest alone. */

.box {
    position: relative;
    display: block;
    width: 100%;
    /* A real case is 110 x 69 x 16mm and holds a 100.5 x 64 cassette. Tilted so
       its top reads as .box__top's 11% of the height, that is a 28 degree lean,
       which foreshortens the 69 to 60.9 and makes the whole thing 110 : 68.4.

       This was 27/20 before, and that was too narrow to be a case at all: the
       front face came out at 1.52 against the cassette's own 1.57, so the tape
       would not have gone in. It reads 1.81 now, which is the real 1.59 seen at
       that same lean. */
    aspect-ratio: 1.608;
    padding: 0;
    border: 0;
    background: transparent;
    cursor: pointer;
    /* Said here as well as on html, and on the keys, because it is the one thing
       on this page a finger reaches for most and the value is not inherited -
       spelling it out is what makes it checkable rather than assumed. */
    touch-action: manipulation;
    container-type: inline-size;
}

/* The part that actually slides. Keeping the movement off the button is what
   stops the hover flickering: a transform carries the hit area with it, so a
   moving button slides out from under the pointer, un-hovers, slides back and
   loops. The button stays put and only this moves inside it. */
.box__body {
    position: absolute;
    inset: 0;
    transition: transform .22s cubic-bezier(.2, .8, .3, 1.2), filter .22s ease;
    /* down and to the right, like the cassette's own - see --tape-shadow */
    filter: drop-shadow(4px 7px 10px rgba(0, 0, 0, .28));
}

/* The boxes lean on each other, each one covering the lower part of the one
   before it - so the rack reads as tops and part-artwork, the way a shelf of
   cases does. Nothing here touches the z-order: later boxes always paint over
   earlier ones, and hovering lifts a box so more of its artwork comes out. */
/* A percentage margin resolves against the **width**, so this is a share of a
   box's width, not its height. Each box is pulled up over the one before, so
   what a box loses is its own foot - the top face and the name stay clear
   whatever this is. Raise it to pack the shelf tighter, lower it to spread it.
   Desktop only: the sideways shelf below 1041px sets margin-top back to 0 and
   overlaps on margin-left instead. */
.box + .box { margin-top: var(--rack-overlap); }

/* Closed up to fit: the step between boxes is what --rack-fit divides into,
   rather than the shelf's usual quarter-width overlap. --rack-count is set
   by renderRack(). A box is 190px wide on the desktop shelf and the case is
   1276 x 844, which is where the height comes from. */
@media (min-width: 1041px) {
    .rack__stack--closed .box + .box {
        margin-top: calc(
            max(var(--rack-step), (var(--rack-fit) - 190px * 844 / 1276) / (var(--rack-count) - 1))
            - 190px * 844 / 1276
        );
    }
}

/* ---- the top face ---- */

/* The pale of this element is the case's plastic rim; the coloured surface is
   ::before, inset inside it so the rim shows along the top and the two sides.
   The rim matches the one round the artwork below - both are the same plastic. */
.box__top {
    position: absolute;
    left: 0;
    right: 0;
    top: 0;
    /* Shallow: a case seen from this angle shows only a little of its top, and
       a deeper band than this reads as a fat box rather than a slim one. The
       face below starts wherever this ends, and BOX_SKEW in player.js is worked
       out from this height - change one and all three have to move. */
    height: 11%;
    background-color: var(--case-shell);
    /* narrow at the far edge, full width where it meets the face - a shallow
       taper, just enough to turn the band into a surface */
    clip-path: polygon(3.5% 0, 96.5% 0, 100% 100%, 0 100%);
}

.box__top::before {
    content: "";
    position: absolute;
    left: 0;
    right: 0;
    top: 1.6px;
    bottom: 0;
    /* the same trapezoid, drawn in a touch from each sloping side */
    clip-path: polygon(4.7% 0, 95.3% 0, 98.7% 100%, 1.3% 100%);
    background-color: var(--tape, #7fb2d6);
    /* it faces the light, so it sits lighter than the face under it */
    background-image: linear-gradient(to bottom,
        rgba(255, 255, 255, .26), rgba(255, 255, 255, .02) 70%, rgba(0, 0, 0, .1));
}

/* The name leans with the surface: skewed to the same angle as the trapezoid's
   sloping sides, which over this band's height works out at 18 degrees. A true
   perspective rotation would foreshorten it in both directions, but at this size
   - the name sets at about 9px - that only mangles the letterforms; a skew stays
   crisp and reads as printing that follows the shape. */
.box__print {
    position: absolute;
    inset: 0;
    display: flex;
    align-items: center;
    /* wide enough that the text stays inside the sloping ends */
    padding: 0 6cqw;
    color: #fff;
    /* centred, because that is where the name sits on a real case */
    justify-content: center;
    text-align: center;
}

/* Each letter leans by where it sits across the band, not the whole name by one
   angle - player.js works the angle out per letter. A single skew was right
   while the name was pinned to the left edge and could borrow that edge's slope;
   centred, it read as the whole name sliding sideways. The extremes are the
   trapezoid's own slope, so a letter at either end lies along the end it is
   nearest and one in the middle stands up straight. */
.box__ch {
    display: inline-block;
    transform-origin: 50% 50%;
}

/* a pale tape colour needs dark lettering; player.js works out which */
.box--pale .box__print { color: #23262b; }
.box--pale .box__name { text-shadow: none; }

.box__name {
    min-width: 0;
    flex: 1 1 auto;
    font-family: Oswald, sans-serif;
    font-weight: 500;
    font-size: 4.6cqw;      /* a point down from 5.2, so it sits inside the taper */
    letter-spacing: .04em;
    line-height: 1.1;
    white-space: nowrap;
    overflow: hidden;
    text-overflow: ellipsis;
    text-shadow: 0 1px 3px rgba(0, 0, 0, .55);
}

/* ---- the face below it ---- */

.box__face {
    position: absolute;
    left: 0;
    right: 0;
    top: 11%;
    bottom: 0;
    background: var(--case-shell);
    /* the body is a trapezoid too, the other way up and far shallower: wider
       where it meets the top face, narrowing very slightly towards its foot */
    clip-path: polygon(0 0, 100% 0, 97.5% 100%, 2.5% 100%);
    /* Where the top face meets the artwork. On a real case you barely see this
       join - the lid is one piece of clear plastic and what catches your eye is
       a shine along the bend, not a drawn line. So it is a light edge with only
       a trace of shadow under it, rather than the hard dark band it was. */
    box-shadow:
        inset 0 1px 0 rgba(255, 255, 255, .5),
        inset 0 3px 5px -4px rgba(0, 0, 0, .2);
}

.box__art {
    position: absolute;
    /* Thinner along the top than round the other three sides, for the same
       reason: the artwork runs almost to the bend, so the rim there is a
       hairline the shine sits on - about a pixel at the rack's own size. */
    inset: 1% 2.2% 3.5%;
    overflow: hidden;
    /* This tape's own colour, which is known before a single byte of artwork
       has arrived - so a cold visit gets a shelf of coloured cases rather than
       a shelf of grey holes. The cover fades in over it; on a tape that has no
       cover at all, this is what stays.

       --box-sheen is the same wash the name band wears, so the two panels are
       the same colour where they meet rather than two shades of it. */
    background-color: var(--tape, #cfd2d5);
    background-image: linear-gradient(var(--box-sheen), var(--box-sheen));
    /* tapered like the body it sits in, measured against its own width */
    clip-path: polygon(0 0, 100% 0, 97.6% 100%, 2.4% 100%);
}

/* Held back until the whole cover is in, for the same reason the cassette is: a
   picture arriving a row at a time is worse to watch than the flat colour it is
   covering. It is a few KB now that lib/thumbs.php sizes them - it was a 2 MB PNG
   drawn 190px wide - so the wait is short, but half a cover still reads as broken.
   .is-in is set on load by renderRack(); an image that errors never gets it, so a
   missing cover leaves the tape's colour rather than a broken one. */
.box__cover {
    width: 100%;
    height: 100%;
    object-fit: cover;
    display: block;
    opacity: 0;
    transition: opacity var(--art-fade) ease;
}

.box__cover.is-in { opacity: 1; }

@media (prefers-reduced-motion: reduce) {
    .box__cover { transition: none; }
}

/* stands in until a tape has its own cover */
/* A tape with no cover at all. It used to hatch the panel with diagonal stripes
   to say so - too noisy, and unnecessary now that .box__art is the tape's own
   colour whether a cover is coming or not. So it paints nothing and lets that
   colour stand; it stays in the markup because it is what .box__cover's absence
   is expressed by. */
.box__plain {
    display: block;
    width: 100%;
    height: 100%;
}

.box__cover[hidden],
.box__plain[hidden] { display: none; }

/* the clear plastic over the top */
.box__case {
    position: absolute;
    inset: 0;
    background:
        linear-gradient(103deg,
            rgba(255, 255, 255, .3) 0 12%,
            rgba(255, 255, 255, .06) 20% 44%,
            rgba(255, 255, 255, .18) 52% 56%,
            rgba(255, 255, 255, .03) 64% 100%),
        /* the left edge catches the light and the right one falls away from
           it, because the light on all of this is up and to the left */
        linear-gradient(90deg,
            rgba(255, 255, 255, .26) 0 2.5%,
            rgba(255, 255, 255, .1) 3.4%,
            transparent 6% 94%,
            rgba(0, 0, 0, .45) 100%);
    box-shadow:
        inset 0 0 0 1px rgba(255, 255, 255, .16),
        inset 0 -2px 6px rgba(0, 0, 0, .4);
    pointer-events: none;
}

/* ------------------------------------------------------------------ *
 * a real case, photographed
 *
 * index.php looks for assets/img/cassette_box.webp; finding it sets --case and
 * puts .rack__stack--case on the shelf, and everything below takes over from
 * the drawn box. Take the file away and the drawing above comes back on its
 * own - nothing here is load-bearing.
 *
 * The photo is a three-quarter view, so the two places artwork goes are not
 * rectangles any more but quadrilaterals in perspective. They were measured off
 * cassette_box_anchors.png, which paints the album-art face #f00 and the name
 * band #008000 on the same 1276 x 844 canvas the shells use.
 *
 * Those blobs were drawn by eye, so every number is a starting point rather
 * than a truth - which is why they are variables. Nudge them here; nothing else
 * needs to know.
 *
 * The art is clipped to the quad **exactly** and then over-filled, which is the
 * whole trick. A skew cannot reproduce a perspective quad - the front face's
 * top edge runs at 13.8 degrees and its bottom at 16.4 - so instead of trying,
 * the cover is skewed to the top edge and drawn bigger than it needs to be, and
 * the clip takes the difference. The edges are therefore always right against
 * the plastic; only the artwork's own insides are approximate, by about 4% at
 * the far corner, which on album art nobody can see.
 * ------------------------------------------------------------------ */

:root {
    /* The outline of the case, for hit testing - the convex hull of everything
       in cassette_box.png that is not transparent, reduced to six points, which
       keeps 98% of it. The plastic is only 54.6% of the artwork's rectangle, and
       the rest used to be clickable. See .box__hit. */
    --case-shape: 12.93% 25.36%, 78.53% 0.83%, 86.99% 7.94%,
                  84.56% 72.51%, 20.06% 99.05%, 13.71% 87.32%;

    /* the album-art face: TL TR BR BL */
    --case-art: 20.38% 31.99%, 84.40% 8.18%, 83.54% 69.55%, 20.53% 96.56%;
    /* the rectangle the cover is drawn into before clipping, and its lean */
    --case-art-x: 20.38%;
    --case-art-y: 31.99%;
    --case-art-w: 64.03%;
    --case-art-h: 64.57%;      /* the taller near edge; the far one over-fills */
    --case-art-skew: -13.82deg;

    /* The name band. A parallelogram to within 3px, so unlike the face it can
       be hit exactly: matrix() maps the element's own rectangle onto it.

       The height is the band's **real** thickness measured across itself - 81
       of the artwork's 844 - not the 43 it happens to span vertically. That is
       what stops the letters coming out stretched: sized to 43 the matrix has
       to scale y by 1.89 to reach the far edge and drags the type with it,
       whereas at 81 the transform only turns and shears, and the only
       foreshortening left is the plane's own, which is the point. */
    --case-name: 15.36% 24.88%, 78.84% 2.13%, 84.25% 7.23%, 20.53% 30.21%;
    --case-name-x: 15.36%;
    --case-name-y: 24.88%;
    --case-name-w: 63.48%;
    --case-name-h: 9.46%;
    --case-name-matrix: matrix(1, -.2370, .8266, .5637, 0, 0);
    --case-name-size: 4.4cqw;
}

/* the photo itself, over everything, with the two windows semi-transparent */
.rack__stack--case .box__case {
    background: var(--case) center / 100% 100% no-repeat;
    box-shadow: none;
    z-index: 3;
}

/* the photo's own proportions, not the drawn box's 1.608 */
.rack__stack--case .box { aspect-ratio: 1276 / 844; }

/* The drawn plastic steps aside - the rim, the taper and the join between the
   two faces are all in the photograph now. What this still owes is the tape's
   own colour, which is the paper showing through the top of the case: cut to
   the band and wearing the same --box-sheen as the art panel, so the two read
   as one colour meeting at the fold rather than two shades of it.

   Its children are clipped with it, which is what keeps a long album name from
   running off the end of the box. */
.rack__stack--case .box__top {
    height: auto;
    inset: 0;
    background-color: var(--tape, #cfd2d5);
    background-image: linear-gradient(var(--box-sheen), var(--box-sheen));
    clip-path: polygon(var(--case-name));
}

.rack__stack--case .box__top::before { display: none; }

.rack__stack--case .box__face {
    inset: 0;
    background: none;
    clip-path: none;
    box-shadow: none;
}

/* the front face. The tape's own colour still stands in here until the cover
   has loaded, exactly as before - it is just cut to the quad now. */
.rack__stack--case .box__art {
    inset: 0;
    clip-path: polygon(var(--case-art));
}

/* Drawn to the quad's bounding box, leaned onto the top edge, and left to
   overhang at the bottom left where the face is shorter. transform-origin is
   the quad's own top-left corner, which is what makes the lean land on TR. */
.rack__stack--case .box__cover,
.rack__stack--case .box__plain {
    position: absolute;
    inset: auto;
    left: var(--case-art-x);
    top: var(--case-art-y);
    width: var(--case-art-w);
    height: var(--case-art-h);
    transform-origin: 0 0;
    transform: skewY(var(--case-art-skew));
}

/* the name, laid on the top face */
.rack__stack--case .box__print {
    position: absolute;
    left: var(--case-name-x);
    top: var(--case-name-y);
    right: auto;
    bottom: auto;
    width: var(--case-name-w);
    height: var(--case-name-h);
    padding: 0;
    display: flex;
    align-items: center;
    transform-origin: 0 0;
    transform: var(--case-name-matrix);
}

.rack__stack--case .box__name {
    font-size: var(--case-name-size);
    line-height: 1;
    white-space: nowrap;
}

/* The letters do not fan on a photograph - see fanRack(), which skips it. It has
   to be stopped there rather than here: fanName() writes the lean as an inline
   style on every letter, and no rule in this file can outrank that. */

/* A box eases out of the rack the way you would slide one off a shelf with a
   finger - and it comes out **along the case's own angle**, up and to the right,
   rather than straight sideways. Each state is one x,y pair from :root, and all
   of them lie on the 13.33 degree line of the fold in the artwork, so hovering a
   tape and then choosing it is a single continuous travel.

   Nothing here touches the z-order: later boxes always paint over earlier ones,
   and the stacking never jumps. */
.box:focus-visible .box__body {
    transform: translate(var(--box-out-near));
    filter: drop-shadow(5px 9px 13px rgba(0, 0, 0, .34));
}

@media (hover: hover) {
    .box:hover .box__body {
        transform: translate(var(--box-out-near));
        filter: drop-shadow(5px 9px 13px rgba(0, 0, 0, .34));
    }
}

.box:focus-visible { outline: none; }

/* ---- what the pointer is actually testing against ----

   The button is a plain rectangle and the case is a photograph of a box seen at
   an angle, so the plastic fills only **54.6%** of it - measured off the alpha
   in cassette_box.png. The rest was live too, which is why a click nowhere near
   a cassette could still pick one, and why the overlapping boxes fought over the
   gaps between them.

   So the button takes no pointer at all and .box__hit does instead: an empty
   pane cut to the silhouette. Six points, from the convex hull of everything in
   the artwork that is not transparent, which keeps 98% of the true shape - a box
   at this angle is a hexagon and there is nothing concave to lose. Being empty,
   clipping it costs nothing visually; clipping .box or .box__body would have cut
   the drop shadow off the case.

   A click in a corner it leaves out now falls through to the box stacked behind,
   which is the one that was being aimed at.

   It is a sibling of .box__body, not a child, so it stays where it is when the
   body slides out. That is the same reason the button never moved: a hit area
   that travels under the pointer un-hovers itself, slides back, and flickers. */
.box { pointer-events: none; }

.box__hit {
    position: absolute;
    inset: 0;
    cursor: pointer;
    pointer-events: auto;
}

/* Only the photograph has a silhouette worth cutting to. The drawn box that
   stands in when cassette_box.webp is missing fills its rectangle, so there it
   keeps the plain one. */
.rack__stack--case .box__hit { clip-path: polygon(var(--case-shape)); }

/* The tape already in the deck is not a thing to press. selectTape() has always
   refused it, so the click did nothing - it just did not look that way: the
   cursor still turned into a hand over an offer the shelf could not keep.

   Deliberately not `disabled`: the button stays in the tab order and keeps
   telling a screen reader which tape is chosen, which is what aria-selected on a
   listbox option is for. It is the mouse that has nothing to do here. */
.box[aria-selected="true"] .box__hit {
    pointer-events: none;
    cursor: default;
}

/* The tape that is loaded has come further out along the same line - it is the
   far end of the travel a hover starts, which is what makes the two read as one
   movement. The sideways shelf on a phone does the opposite and rises only,
   since out is where the next box is. */
.box[aria-selected="true"] .box__body { transform: translate(var(--box-out-far)); }

.box[aria-selected="true"]:focus-visible .box__body { transform: translate(var(--box-out-most)); }

/* There is deliberately no selected:hover rule: that box takes no pointer at
   all now, so :hover can never match on it. */

/* ------------------------------------------------------------------ *
 * the cassette
 * ------------------------------------------------------------------ */

.cassette {
    position: relative;
    width: 100%;
    aspect-ratio: 1276 / 844;
    container-type: inline-size;
    perspective: 1700px;
    /* no drop-shadow here: it goes on .cassette__art instead, because a filter
       on an ancestor of the 3D box below would flatten it - see --tape-shadow */
}

/* ---- the cassette as a box ----
   Measured off the shell artwork: the body sits at x 34-1238, y 45-794 of the
   1276 x 844 image, so it is inset 2.66% / 2.90% / 5.33% / 5.81% and is centred -
   which is why it can be spun about the middle of this element.

   Those came off the shell this was built on before the five. Theirs is
   x 33-1242, y 47-796: the same 88.86% tall and 94.83% rather than 94.44% wide,
   which moves half a shell by 0.19cqw. Left alone deliberately - the flip took
   three attempts to get right and 0.19cqw is not worth reopening it - but that
   is the number to correct if a seam ever shows along an edge while it turns.

       shell width   94.44% of the image, so half is 47.22cqw
       shell height  88.86% of it, and the image is 66.14cqw tall,
                     so the shell is 58.78cqw and half is 29.39cqw

   The front face keeps the whole image, padding and baked shadow included, so
   nothing about the cassette at rest changes; only the four edges and the back
   are pinned to the shell body. */
.cassette__box {
    position: absolute;
    inset: 0;
    transform-style: preserve-3d;
    /* rotate about the middle of the box, then push it back by half its depth,
       so at rest the front face lands at z 0 and is not scaled by perspective */
    transform: translateZ(calc(var(--tape-depth) / -2)) rotateY(var(--turn, 0deg));
    /* The turn is the middle phase of the flip, not the whole of it: it waits
       out --flip-lead while the tape is lifted clear, runs for what is left
       between the two lifts, and is over before the tape goes back in. The delay
       is why the rotation can be started at the same moment as the lift and
       still come second. */
    transition: transform
        calc(var(--tape-flip) * (1 - 2 * var(--flip-lead)))
        ease-in-out
        calc(var(--tape-flip) * var(--flip-lead));
}

/* Where the label face sits in the box, and which way it points. Turning the
   tape over swaps both - see the flip note below. The back is always the
   opposite: the other side of the box, pointing the other way. */
/* Where the label face sits in the box, and which way it points. Turning the
   tape over swaps both - see the flip note below. There is no back face and no
   filler slab behind: the label always ends up facing front, so the far side of
   the box is never on screen, and anything put there only shows as a dark shape
   behind the cassette while it turns. */
.cassette__front {
    position: absolute;
    backface-visibility: hidden;
    inset: 0;
    transform: translateZ(var(--face-z, 0px)) rotateY(var(--face-turn, 0deg));
}

/* ---- the thickness ----
   Only the two long sides, and only one of those is ever visible: the near one
   is drawn, the far one is turned away and hidden by backface-visibility. The
   top and bottom are gone - edge on they were a sliver running the full width
   of the tape, which read as an outline round the artwork rather than as depth.

   They sit at 43.5cqw rather than out at the shell's edge (47.22) because the
   artwork has no hard edge: its alpha ramps down over about 25px on the left and
   is still only ~140 at the last pixel on the right. Anything placed at the true
   edge leaves the page showing through the translucent border beside it, which
   is where the white seam came from. Tucked inside the opaque part - the shell
   is solid from about 44cqw inwards - the artwork overhangs the slab and covers
   the join. */
.cassette__edge {
    position: absolute;
    display: block;
    top: 7.5%;
    bottom: 8%;
    width: var(--tape-depth);
    left: calc(50% - var(--tape-depth) / 2);
    backface-visibility: hidden;
    visibility: hidden;
}

.cassette--flipping .cassette__edge { visibility: visible; }

/* shaded away from the label, so the side reads as going back into the picture */
.cassette__edge--right {
    transform: rotateY(90deg) translateZ(43.5cqw);
    background: linear-gradient(to right, #4b5058 0%, #34383f 60%, #24272c 100%);
}

.cassette__edge--left {
    transform: rotateY(-90deg) translateZ(43.5cqw);
    background: linear-gradient(to left, #3f444b 0%, #2b2f35 60%, #1c1f23 100%);
}

/* Changing tapes, as a hand would do it. One tape is picked up, lifted away and
   let go of; the other rises into its place still held, and is only lowered onto
   the deck once it is there. Both moves run at once and the two cassettes pass
   each other, so the deck is never empty - but neither one ever cross-fades
   through the other in the same spot, which is what looked so bad before.

   The scale is the "held" state, and it is the same 1.07 on both, so the tape
   arriving is exactly as big as the one leaving at the moment they swap over.
   player.js leaves a copy of the outgoing one behind in .stage to play tapeOut. */
/* The cassette is the one thing above the inlay that is allowed to give. The
   inlay keeps its size - it is half the deck wide and that same square tall -
   so on a short screen the deck shrinks the tape rather than the paper.

   Below the cassette sit 593px of transport, counter, inlay and their margins,
   measured, and the cassette is .662 of its own width. That is what this solves
   for: the inlay bottom lands on the fold instead of under it when the page
   first opens. It never goes below 360, and 600 still wins on a tall screen.
   The page may still scroll past the footer - that part is fine. */
.stage {
    position: relative;
    max-width: clamp(360px, calc((100vh - 593px) / 0.662), 600px);
    margin: 0 auto;
}

/* the tape arriving is nearer the front than the one being taken away */
.stage > .cassette { z-index: 1; }

.stage .cassette--leaving {
    position: absolute;
    inset: 0;
    z-index: 0;
    pointer-events: none;
}

/* Turning the tape over, done with hands rather than in the deck. The rotateY on
   .cassette__box is the tape itself turning; this is the pair of hands round it -
   it comes up off the deck, turns, and goes back down. The same gesture tapeOut
   uses to lift a tape off, only it comes back.

   It rides on .stage and not on .cassette for two reasons: .cassette carries the
   perspective:1700px the rotation is *seen* through, and its animation slot is
   already taken by --arriving and --leaving, which are never removed once set.

   One duration, --tape-flip - the same value the rotation transitions over and
   the same one flipPause() measures the flip sound against - so the lift, the
   turn and the noise begin and end together. Change the timing in one place. */
.stage--flipping { animation: tapeTurn var(--tape-flip) ease-in-out; }

/* Out, over, back - in that order, not all at once. The tape is pulled clear of
   the deck first, turns while it is held out, and only then goes back in. The
   20% and 80% stops are --flip-lead; the rotation waits out the first quarter
   and is finished by the third, which is done with the transition delay on
   .cassette__box, so all three phases share the one --tape-flip and still land
   with the flip sound. */
@keyframes tapeTurn {
    0% { transform: translateY(0) scale(1); }
    20%, 80% {
        transform: translateY(calc(var(--flip-rise) * -1)) scale(var(--flip-reach));
    }
    100% { transform: translateY(0) scale(1); }
}

@media (prefers-reduced-motion: reduce) {
    .stage--flipping { animation: none; }
}

/* will-change only while they are actually moving, which is what it is for.
   tapeIn reaches full opacity partway through and then changes scale, and
   without this the layer can be thrown away and rebuilt at exactly that seam -
   the moment the cassette was seen to blink on a phone. warmShell() in
   player.js deals with the other half of that, which was the bitmap not being
   decoded yet; this makes sure the raster is not thrown away either. */
.cassette--leaving {
    animation: tapeOut var(--tape-out) both;
    will-change: transform, opacity;
}
.cassette--arriving {
    animation: tapeIn var(--tape-in) cubic-bezier(.25, .55, .3, 1) var(--tape-in-delay) both;
    will-change: transform, opacity;
}

@keyframes tapeOut {
    /* picked up off the deck, still in place */
    0% {
        transform: translateY(0) scale(1);
        opacity: 1;
        animation-timing-function: cubic-bezier(.2, .8, .3, 1);
    }
    /* held, and starting to move */
    16% {
        transform: translateY(0) scale(1.07);
        opacity: 1;
        animation-timing-function: cubic-bezier(.45, 0, .8, .5);
    }
    /* moved a little before any of it fades - it is being carried, not dissolved */
    32% {
        transform: translateY(-6%) scale(1.07);
        opacity: 1;
    }
    /* then it goes out slowly, over most of the climb */
    92% {
        opacity: 0;
    }
    /* carried up and away, gone before it reaches the masthead */
    100% {
        transform: translateY(-34%) scale(1.07);
        opacity: 0;
    }
}

@keyframes tapeIn {
    /* below the deck, already held at the same size as the one leaving. The wait
       before any of this happens is --tape-in-delay, not a hold in here. */
    0%   { transform: translateY(36%) scale(1.07); opacity: 0; }
    48%  { opacity: 1; }
    /* arrived over the deck, still held */
    74%  { transform: translateY(0) scale(1.07); opacity: 1; }
    /* and lowered onto it */
    100% { transform: translateY(0) scale(1); opacity: 1; }
}

/* The measuring marks across the window. Lighter than the smoked plastic they
   are printed on and barely there, the way a real one's are - they should read as
   marks on the window, never as bars across it. index.php lays them out; this is
   only what they look like, so it is the one to touch to make them fainter. */
.tick {
    stroke: rgba(255, 255, 255, .17);
    stroke-width: 2;
}

.cassette__art {
    position: absolute;
    inset: 0;
    width: 100%;
    height: 100%;
    /* The artwork's silhouette is what casts it. This sits on the <svg> rather
       than on .cassette, because a filter on an ancestor of the 3D box would
       flatten it and kill the flip. */
    filter: drop-shadow(var(--tape-shadow));
}

/* Everything below is measured off the two sticker PNGs (1276 x 844) and
   written as a percentage, so the four bits of text land in the boxes drawn on
   the label at any size. The boxes are cassette_sticker_label.png: the side box
   x 133-221 y 138-226, and the writing box x 241-1134 y 138-226 with its dashed
   rule at y 182. The band under them is the accent's lower stripe, y 532-599.
   Re-measure all four if either sticker is ever redrawn. */
.cassette__write {
    position: absolute;
    inset: 0;
    pointer-events: none;
}

.write {
    position: absolute;
    display: flex;
    align-items: flex-end;
    gap: 1.2cqw;
    margin: 0;
    overflow: hidden;
    font-family: "Bright Chalk", "Segoe Print", "Bradley Hand", cursive;
    line-height: 1;
    color: #24262b;
}

/* Sits on the dashed rule (y 182), which runs x 260-1114 - but the writing
   stops short of the box's own end at x 1120, so a long title with a date after
   it cannot run out over the rounded corner. */
.write--title {
    left: 20.30%;     /* 259 */
    top: 14.34%;      /* 121 */
    width: 67.50%;    /* to 1120 */
    height: 7.35%;    /* down to the rule at 183 */
    font-size: 3.6cqw;
}

/* Under the rule and inside the same box, which now ends at y 226 rather than
   the old label strip's 251 - so this line is both higher and a size smaller
   than it was, to sit clear of the box's bottom edge. */
.write--artist {
    left: 20.30%;
    top: 21.92%;      /* 185, just under the rule */
    width: 67.50%;
    height: 4.74%;    /* 40, stopping at 225 */
    font-size: 2.7cqw;
    color: #5c5f66;
}

.write__num { flex: none; }

/* When the song was made, written at the end of the artist line. Bright Chalk
   sets its digits tight enough to touch, so they are spaced out a little. */
.write__date {
    flex: none;
    margin-left: auto;
    padding-left: 2cqw;
    font-size: .78em;
    letter-spacing: .14em;
    color: #6b6f77;
}

.write__value {
    min-width: 0;
    flex: 0 1 auto;
    white-space: nowrap;
    overflow: hidden;
    text-overflow: ellipsis;
}

/* Which way up the tape is, in the box drawn on the label at x 133-221,
   y 138-226. Click it to turn the tape over. That box is printed pale grey,
   not the red square it used to be, so the letter on it is ink rather than
   white and there is no shadow under it. */
.side {
    position: absolute;
    left: 10.42%;
    top: 16.35%;
    width: 6.97%;
    height: 10.55%;
    display: flex;
    align-items: center;
    justify-content: center;
    padding: 0;
    border: 0;
    border-radius: 16%;
    background: transparent;
    color: #3a3d42;
    /* printed on the shell, not written on it - the same face as the length */
    font-family: Oswald, sans-serif;
    font-weight: 500;
    font-size: 4.6cqw;
    letter-spacing: .04em;
    line-height: 1;
    cursor: pointer;
    transition: background-color .15s ease;
}

@media (hover: hover) {
    .side:hover:not(:disabled) { background: rgba(0, 0, 0, .07); }
}
.side:focus-visible { outline: 2px solid #3a3d42; outline-offset: 1px; }
.side:disabled { cursor: default; }

/* The band along the bottom, x 107-1168, y 532-599. It is the lower stripe of
   the accent sticker, so it is already in the tape's colour - nothing is painted
   over it here. The tape's name is set along the left of it and its length at
   the right, both in whatever can be read on that colour - see --album-ink in
   player.js. */
.bandname,
.length {
    position: absolute;
    top: 63.03%;      /* 532 */
    height: 7.94%;    /* 67 */
    display: flex;
    align-items: center;
    pointer-events: none;
    color: var(--album-ink, #fff);
    font-family: Oswald, sans-serif;
    font-weight: 500;
    line-height: 1;
}

.bandname {
    left: 10.6%;
    max-width: 60%;
    font-size: 3.4cqw;
    letter-spacing: .1em;
    text-transform: uppercase;
    white-space: nowrap;
    overflow: hidden;
    text-overflow: ellipsis;
}

.length {
    right: 10.66%;
    font-size: 4cqw;
    letter-spacing: .04em;
}

/* Turning the tape over. A cassette is rotated about its vertical axis to play
   the other side - left and right spools swap, the exposed edge stays at the
   bottom - so this is a rotateY on the box, and it is a **half** turn: 180
   degrees, once, exactly as the object does it. --turn accumulates, so each
   flip carries on from where the last one left off.

   The catch is that we have art for one face only, so stopping at 180 would
   leave the plain back of the shell facing out. So the label face turns over
   too, at the halfway point: --face-z flips it to the other side of the box and
   --face-turn spins it 180, which together cancel the box's rotation and leave
   it facing front, unmirrored, at z 0 again. The swap happens while the box is
   edge on and the face has no width on screen, so it cannot be seen - all you
   see at that moment is the four edge slabs, which is the whole point of the
   box having sides. */
.cassette {
    --face-z: calc(var(--tape-depth) / 2);
    --face-turn: 0deg;
}

.cassette--turned {
    --face-z: calc(var(--tape-depth) / -2);
    --face-turn: 180deg;
}

/* ------------------------------------------------------------------ *
 * transport - a row of metal keys on a glass plate
 * ------------------------------------------------------------------ */

.transport {
    display: flex;
    justify-content: center;
    width: max-content;
    max-width: 100%;
    margin: 10px auto 0;
    padding: 8px;
    border: 1px solid rgba(0, 0, 0, .1);
    border-bottom-color: rgba(255, 255, 255, .6);
    border-radius: var(--radius);
    background: linear-gradient(180deg,
        rgba(170, 170, 170, .35) 0%, rgba(255, 255, 255, .44) 50%, rgba(255, 255, 255, .53) 100%);
    box-shadow:
        inset 0 1px 0 rgba(0, 0, 0, .05),
        inset 0 2px 19px rgba(0, 0, 0, .05),
        0 1px 0 rgba(255, 255, 255, .8),
        0 2px 1px rgba(0, 0, 0, .06);
}

.key {
    width: 86px;
    height: 56px;
    display: grid;
    place-items: center;
    border: 0;
    padding: 0;
    cursor: pointer;
    /* no waiting to see whether a tap is a double-tap zoom - a deck key
       answers the moment it is touched. The blue flash is dealt with once at
       the top of this file, for everything. */
    touch-action: manipulation;
    background: #ddd url(../img/metal.jpg) center / cover no-repeat;
    box-shadow:
        inset 0 0 0 1px rgba(0, 0, 0, .2),
        inset 0 0 1px 2px rgba(255, 255, 255, .9),
        inset 0 -6px 5px rgba(0, 0, 0, .1),
        0 6px 7px rgba(0, 0, 0, .3),
        0 4px 1px rgba(0, 0, 0, .5);
    transition: transform .07s ease, box-shadow .07s ease;
}

.key--play { width: 118px; }
.key:first-child { border-radius: 8px 0 0 8px; }
.key:last-child { border-radius: 0 8px 8px 0; }

.key svg {
    width: 20px;
    height: 20px;
    fill: #444;
    filter: drop-shadow(1px 1px 1px rgba(255, 255, 255, .9));
}

/* the original raised key, a touch deeper in the shade, sitting 2px lower.
   Behind (hover: hover) because a finger has no hover: a phone paints :hover on
   tap and leaves it painted, so the key stays looking half-pressed long after
   it was let go. Everything below that reacts to a pointer is guarded the same
   way. */
@media (hover: hover) {
    .key:hover:not(:disabled) {
        transform: translateY(2px);
        box-shadow:
            inset 0 0 0 1px rgba(0, 0, 0, .2),
            inset 0 0 1px 2px rgba(255, 255, 255, .9),
            inset 0 -10px 15px rgba(0, 0, 0, .22),
            0 6px 7px rgba(0, 0, 0, .3),
            0 4px 1px rgba(0, 0, 0, .5);
    }
}

/* pressed by hand, or latched down because that is what the deck is doing */
.key:active:not(:disabled),
.key--down {
    background-image: url(../img/metal_dark.jpg);
    transform: translateY(4px);
    box-shadow:
        inset 0 0 0 1px rgba(0, 0, 0, .2),
        inset 0 0 5px 1px rgba(255, 255, 255, .5),
        inset 0 -10px 15px rgba(0, 0, 0, .2),
        0 2px 3px rgba(0, 0, 0, .35);
}

.key:focus-visible {
    outline: 2px solid #6f9fc4;
    outline-offset: 3px;
}

/* a key that is held down is not faded - it is just not pressable again */
.key:disabled { cursor: default; }
.key:disabled:not(.key--down) { opacity: .45; }

/* the record key: red circle, and it stays down while the song is on the
   mixtape, the way a record button stays engaged */
.key--rec svg { fill: #b3392c; }

.key--rec[aria-pressed="true"] svg {
    fill: #e0402c;
    filter: drop-shadow(0 0 5px rgba(224, 64, 44, .85));
}

.key--rec[aria-pressed="true"]:not(:disabled) {
    background-image: url(../img/metal_dark.jpg);
    box-shadow:
        inset 0 0 0 1px rgba(0, 0, 0, .2),
        inset 0 0 5px 1px rgba(255, 255, 255, .5),
        inset 0 -10px 15px rgba(0, 0, 0, .2),
        0 2px 3px rgba(0, 0, 0, .35);
    transform: translateY(4px);
}

/* ------------------------------------------------------------------ *
 * the word from the deck - only ever on screen for a few seconds
 * ------------------------------------------------------------------ */

.notice {
    max-width: 420px;
    margin: 12px auto 0;
    padding: 8px 14px;
    border-radius: var(--radius);
    background: rgba(179, 57, 44, .1);
    box-shadow: inset 0 0 0 1px rgba(179, 57, 44, .3);
    color: #8c2d23;
    font-family: Oswald, sans-serif;
    font-size: .8rem;
    letter-spacing: .04em;
    text-align: center;
    animation: noticeIn .18s ease both;
}

.notice[hidden] { display: none; }

@keyframes noticeIn {
    from { opacity: 0; transform: translateY(-4px); }
    to   { opacity: 1; transform: translateY(0); }
}

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

.counter {
    display: flex;
    align-items: center;
    gap: 12px;
    max-width: 420px;
    margin: 12px auto 0;
}

.counter__time {
    min-width: 40px;
    text-align: center;
    font-family: Oswald, sans-serif;
    font-size: .78rem;
    letter-spacing: .08em;
    color: var(--ink-soft);
}

/* Registered so it can be transitioned. Buffering arrives in jumps - a range
   can grow by half a song in a single event - and a bar that leaps reads as a
   glitch where one that slides reads as loading. A browser that does not know
   @property still gets the band, just without the slide. */
@property --buffered {
    syntax: '<percentage>';
    inherits: false;
    initial-value: 0%;
}

/* Three bands, not two: what has been heard, what is on the tape ready to be
   heard, and what has not arrived yet. The songs are 320 kbps, so on a phone
   there is a real stretch of song sitting in front of the playhead and it is
   worth being able to see - a band that keeps its distance ahead means the
   music will not stop, and one the playhead is catching up with means it will.

   --buffered is read off the buffered range the playhead is actually inside,
   so after a seek it starts from nothing again rather than counting a stretch
   downloaded elsewhere in the song. It defaults to 0%: a gradient stop behind
   the one before it is clamped up to meet it, so the band simply has no width
   until there is something to show. */
.counter__seek {
    flex: 1;
    -webkit-appearance: none;
    appearance: none;
    height: 5px;
    border-radius: 3px;
    background: linear-gradient(90deg,
                                var(--seek-played) 0 var(--played, 0%),
                                var(--seek-buffered) var(--played, 0%) var(--buffered, 0%),
                                var(--seek-empty) var(--buffered, 0%) 100%);
    box-shadow: inset 0 1px 2px rgba(0, 0, 0, .25);
    cursor: pointer;
    transition: --buffered var(--seek-buffer-slide) linear;
}

.counter__seek::-webkit-slider-thumb {
    -webkit-appearance: none;
    width: 15px;
    height: 15px;
    border-radius: 50%;
    background: linear-gradient(180deg, #fff, #d3d6da);
    box-shadow: 0 1px 3px rgba(0, 0, 0, .4), inset 0 0 0 1px rgba(0, 0, 0, .15);
}

.counter__seek::-moz-range-thumb {
    width: 15px;
    height: 15px;
    border: 1px solid rgba(0, 0, 0, .15);
    border-radius: 50%;
    background: linear-gradient(180deg, #fff, #d3d6da);
}

/* ------------------------------------------------------------------ *
 * the cassette picker, between the deck and the transport
 *
 * Its height never changes. On any tape but the mixtape it is hidden with
 * visibility rather than display, so the transport below it does not move
 * when you pick a different tape - the one thing this control must not do.
 * ------------------------------------------------------------------ */

.shells {
    display: flex;
    justify-content: center;
    gap: 9px;
    /* It lives *in* the gap that was already there rather than adding a band of
       its own: the deck-to-transport distance goes 16px to 31, not to 64. The
       chips carry no captions for the same reason - the tooltip and the
       aria-label say which is which without costing a line of type. */
    margin: 2px auto 0;
    visibility: hidden;
}

.deck--mixtape .shells { visibility: visible; }

.shells__pick {
    display: flex;
    flex-direction: column;
    align-items: center;
    gap: 4px;
    /* a finger needs more than a 19px chip, so the button is padded round it */
    padding: 4px 3px;
    border: 0;
    background: transparent;
    cursor: pointer;
}

/* a cassette seen end on, in the plastic it is made of */
.shells__chip {
    width: 30px;
    height: 19px;                 /* 1.6:1, a case's own proportion */
    border-radius: 3px;
    background: var(--swatch);
    box-shadow:
        inset 0 1px 0 rgba(255, 255, 255, .35),
        inset 0 -2px 4px rgba(0, 0, 0, .25),
        0 1px 2px rgba(0, 0, 0, .3);
    transition: transform .15s ease, box-shadow .15s ease;
}

.shells__pick[data-shell="cassette_black"]  { --swatch: #414042; }
.shells__pick[data-shell="cassette_red"]    { --swatch: #cc393a; }
.shells__pick[data-shell="cassette_orange"] { --swatch: #ff903d; }
.shells__pick[data-shell="cassette_white"]  { --swatch: #e1dada; }
/* the clear one really is see-through, so the page shows through the chip */
.shells__pick[data-shell="cassette_glass"]  { --swatch: rgba(74, 74, 76, .5); }

/* There for a screen reader, not for the eye - the colours say it faster. */
.shells__name {
    position: absolute;
    width: 1px;
    height: 1px;
    margin: -1px;
    padding: 0;
    overflow: hidden;
    clip-path: inset(50%);
    white-space: nowrap;
}

.shells__pick[aria-checked="true"] .shells__chip {
    transform: translateY(-2px);
    box-shadow:
        inset 0 1px 0 rgba(255, 255, 255, .35),
        inset 0 -2px 4px rgba(0, 0, 0, .25),
        0 0 0 2px var(--ink-soft),
        0 3px 5px rgba(0, 0, 0, .3);
}

@media (hover: hover) {
    .shells__pick:hover .shells__chip { transform: translateY(-2px); }
}

.shells__pick:focus-visible { outline: 2px solid var(--ink-soft); outline-offset: 3px; }

/* ---- the sticker colour, at the left end of the row ----
   Where Share is at the right: out of the flow like it, so the chips stay
   centred and it can never crowd them or Share on a narrow screen. A swatch
   in the sticker's colour right now, inside a dotted ring - deliberately not
   a sixth chip, which it was, and which said "another shell". */
.shells__tint {
    position: absolute;
    /* flush with the transport's left edge, which is 10% in from the deck's -
       measured to the dotted ring's outer edge, not the swatch, because the
       ring is what the eye takes for the edge of the control */
    left: calc(10% + var(--tint-ring));
    top: 0;
    bottom: 0;
    display: flex;
    align-items: center;
    padding: 0;
    border: 0;
    background: transparent;
    cursor: pointer;
}

.shells__tintSwatch {
    display: block;
    width: 30px;
    height: 19px;
    padding: 0;
    border-radius: 3px;
    background: var(--album);
    box-shadow:
        inset 0 1px 0 rgba(255, 255, 255, .35),
        inset 0 -2px 4px rgba(0, 0, 0, .25),
        0 1px 2px rgba(0, 0, 0, .3);
    /* the ring is a dotted line a little way out from the swatch, not a
       border on it, so the colour stays a clean block like the chips */
    outline: 1px dotted var(--ink-soft);
    outline-offset: calc(var(--tint-ring) - 1px);
    transition: transform .15s ease;
}

@media (hover: hover) {
    .shells__tint:hover .shells__tintSwatch { transform: translateY(-2px); }
}

.shells__tint[aria-expanded="true"] .shells__tintSwatch { transform: translateY(-2px); }
.shells__tint:focus-visible { outline: 2px solid var(--ink-soft); outline-offset: 6px; }

/* somebody else's tape: their colour, not a control */
.deck--shared .shells__tint { visibility: hidden; }

/* ---- the colour card ----
   A small piece of the inlay's paper hung under the row, with three sliders.
   Not modal, and it never covers the cassette above it: the whole point is to
   watch the sticker change while a slider is dragged. The browser's own
   <input type="color"> was here first, and on a phone it is a full-screen
   sheet with a grid of swatches that hides the tape. */
.tint {
    position: absolute;
    left: 50%;
    top: calc(100% + 8px);
    z-index: 30;
    width: min(340px, calc(100vw - 32px));
    padding: 12px 14px 10px;
    transform: translateX(-50%);
    border-radius: 3px;
    background: var(--paper);
    color: var(--paper-ink);
    box-shadow:
        0 1px 1px rgba(60, 50, 30, .2),
        0 10px 24px rgba(60, 50, 30, .28);
}

.tint[hidden] { display: none; }

.tint__row {
    display: grid;
    grid-template-columns: 4.2em 1fr;
    align-items: center;
    gap: 10px;
    margin-bottom: 8px;
}

.tint__name {
    color: #9a917c;
    font-family: Oswald, sans-serif;
    font-size: .62rem;
    letter-spacing: .24em;
    text-transform: uppercase;
}

/* Each track is a picture of what that slider would do from where the other
   two are, so the choice is visible before it is made. The three numbers are
   set on .tint by paintTintCard(). */
.tint__slider {
    width: 100%;
    height: 14px;
    margin: 0;
    border-radius: 7px;
    background: #ccc;
    -webkit-appearance: none;
    appearance: none;
    cursor: pointer;
}

.tint__slider--hue {
    background: linear-gradient(to right,
        hsl(0, var(--tint-s), var(--tint-l)), hsl(60, var(--tint-s), var(--tint-l)),
        hsl(120, var(--tint-s), var(--tint-l)), hsl(180, var(--tint-s), var(--tint-l)),
        hsl(240, var(--tint-s), var(--tint-l)), hsl(300, var(--tint-s), var(--tint-l)),
        hsl(360, var(--tint-s), var(--tint-l)));
}

.tint__slider--sat {
    background: linear-gradient(to right,
        hsl(var(--tint-h), 0%, var(--tint-l)), hsl(var(--tint-h), 100%, var(--tint-l)));
}

.tint__slider--lit {
    background: linear-gradient(to right,
        hsl(var(--tint-h), var(--tint-s), 0%), hsl(var(--tint-h), var(--tint-s), 50%),
        hsl(var(--tint-h), var(--tint-s), 100%));
}

.tint__slider::-webkit-slider-thumb {
    -webkit-appearance: none;
    width: 18px;
    height: 18px;
    border-radius: 50%;
    border: 2px solid #fff;
    background: hsl(var(--tint-h), var(--tint-s), var(--tint-l));
    box-shadow: 0 1px 3px rgba(0, 0, 0, .4);
}

.tint__slider::-moz-range-thumb {
    width: 14px;
    height: 14px;
    border-radius: 50%;
    border: 2px solid #fff;
    background: hsl(var(--tint-h), var(--tint-s), var(--tint-l));
    box-shadow: 0 1px 3px rgba(0, 0, 0, .4);
}

.tint__slider:focus-visible { outline: 2px solid var(--album-on-paper, #6b7178); outline-offset: 2px; }

.tint__foot {
    display: flex;
    align-items: center;
    justify-content: space-between;
    margin-top: 4px;
}

.tint__hex {
    color: #9a917c;
    font-family: ui-monospace, "Cascadia Mono", Consolas, monospace;
    font-size: .74rem;
    letter-spacing: .06em;
}

/* ---- the words at the right end of the same row ----
   Share on the mixtape; on a tape somebody else shared, its link and the way
   off the shelf. Taken out of the flow so the chips sit exactly where they
   did whatever is printed beside them, and the row's height is unchanged. */
.shells { position: relative; }

.shells__acts {
    position: absolute;
    /* flush with the transport's right edge, which is 10% in from the deck's */
    right: 10%;
    top: 0;
    bottom: 0;
    display: flex;
    align-items: center;
    gap: 14px;
}

.shells__act {
    display: none;
    padding: 4px 9px;
    /* Dashed, so it reads as a thing to press rather than a caption - the
       plain word was too easy to miss beside the chips. */
    border: 1px dashed var(--ink-soft);
    border-radius: 3px;
    background: transparent;
    color: var(--ink-soft);
    font-family: Oswald, sans-serif;
    font-size: .66rem;
    letter-spacing: .22em;
    text-transform: uppercase;
    white-space: nowrap;
    cursor: pointer;
    transition: color .15s ease;
}

.shells__act[disabled] { cursor: default; opacity: .38; }

@media (hover: hover) {
    .shells__act:not([disabled]):hover { color: var(--ink); border-color: var(--ink); }
}

.shells__act:focus-visible { outline: 2px solid var(--ink-soft); outline-offset: 2px; }

.deck--mixtape .shells__act--mix { display: inline-block; }
.deck--shared .shells__act--shared { display: inline-block; }

/* a tape off somebody else's link: the row shows, the chips do not - the
   cassette it is on was their choice */
.deck--shared .shells { visibility: visible; }
.deck--shared .shells__pick { visibility: hidden; }

/* ---- the mixtape's name is the way to change it ----
   The band on the shell and the spine of the inlay print it already; on the
   mixtape they take a click. A pencil sits beside the name the whole time -
   on a phone nothing else could say the name is there to be changed - and
   brightens under a pointer. A hand, not a text cursor: it opens a sheet. */
.is-editable {
    cursor: pointer;
    pointer-events: auto;       /* the band is inert by default */
}

/* No transition on the pencil, on purpose. Animating its opacity put it on a
   compositing layer for the duration, and the letters beside it were
   re-rasterised under that - which read as the name twitching on hover. It
   just changes. Nothing about the text itself does. */
.is-editable::after {
    content: " \270E";
    opacity: .55;
}

.is-editable:focus-visible { outline: 2px solid var(--album-ink, #fff); outline-offset: 2px; }

/* The whole flap the spine is printed on takes the click and the hover, not
   only the letters - it is a narrow strip and the words are a small part of
   it. The band on the shell is its own text, so it hovers on itself. */
.is-editable-flap { cursor: pointer; }

@media (hover: hover) {
    .is-editable-flap:hover .is-editable::after,
    .bandname.is-editable:hover::after { opacity: 1; }
}

/* ---- the sheet: a name and a picture, or a link ----
   A small piece of the same paper the inlay is on, over a dimmed page. */
.sheet {
    width: min(420px, calc(100vw - 32px));
    padding: 0;
    border: 0;
    border-radius: 3px;
    background: var(--paper);
    color: var(--paper-ink);
    box-shadow:
        0 1px 1px rgba(60, 50, 30, .2),
        0 14px 34px rgba(60, 50, 30, .3);
}

.sheet::backdrop { background: rgba(30, 28, 24, .45); }

.sheet__form {
    display: flex;
    flex-direction: column;
    gap: 6px;
    padding: 18px 20px 16px;
}

.sheet__title {
    margin: 0 0 8px;
    font-family: Oswald, sans-serif;
    font-weight: 500;
    font-size: 1rem;
    letter-spacing: .18em;
    text-transform: uppercase;
}

.sheet__label {
    margin-top: 6px;
    color: #9a917c;
    font-family: Oswald, sans-serif;
    font-size: .66rem;
    letter-spacing: .26em;
    text-transform: uppercase;
}

.sheet__input {
    width: 100%;
    min-width: 0;
    padding: 7px 9px;
    border: 1px solid var(--paper-rule);
    border-radius: 2px;
    background: #fff;
    color: var(--paper-ink);
    font: inherit;
    font-size: .92rem;
}

.sheet__input:focus { outline: 2px solid var(--album-on-paper, #6b7178); outline-offset: -1px; }

.sheet__hint {
    margin: 0;
    color: #9a917c;
    font-size: .8rem;
    line-height: 1.4;
}

.sheet__hint--lead { color: var(--paper-ink); margin-bottom: 4px; }

.sheet__linkrow {
    display: flex;
    gap: 8px;
}

.sheet__row {
    display: flex;
    justify-content: flex-end;
    gap: 10px;
    margin-top: 14px;
}

.sheet__btn {
    padding: 7px 14px;
    border: 1px solid var(--paper-rule);
    border-radius: 2px;
    background: transparent;
    color: var(--paper-ink);
    font-family: Oswald, sans-serif;
    font-size: .72rem;
    letter-spacing: .2em;
    text-transform: uppercase;
    cursor: pointer;
    white-space: nowrap;
}

.sheet__btn--go {
    border-color: var(--album-on-paper, var(--album));
    background: var(--album-on-paper, var(--album));
    color: #fff;
}

.sheet__btn--quiet {
    margin-right: auto;
    border-color: transparent;
    color: #9a917c;
}

.sheet__btn[hidden] { display: none; }

@media (hover: hover) {
    .sheet__btn:hover { border-color: var(--album-on-paper, var(--album)); }
    .sheet__btn--quiet:hover { color: #8c2d23; border-color: transparent; }
}

.sheet__btn:focus-visible { outline: 2px solid var(--album-on-paper, #6b7178); outline-offset: 2px; }

/* ------------------------------------------------------------------ *
 * the inlay - the paper folded into the cassette box
 *
 * Three panels across: the spine (the folded-back flap, which throws a
 * shadow onto the panel beside it), the cover with the words over it,
 * and the running order. The middle panel is square, and that is what
 * sets the height of the whole sheet - so nothing here grows or shrinks
 * with the length of a lyric.
 * ------------------------------------------------------------------ */

:root {
    --paper: #f4f0e6;
    --paper-edge: #ddd6c4;
    --paper-ink: #2f2c26;
    --paper-rule: #cdc5b2;
}

.inlay {
    display: grid;
    /* Wider than the deck it belongs to. .player is pinned at 600 because the
       cassette is sized off it, so rather than widening that and dragging the
       cassette along, the paper steps outside it and re-centres itself. */
    width: var(--inlay-w);
    max-width: calc(100vw - 32px);
    margin-left: 50%;
    transform: translateX(-50%);
    /* Two halves. The left one carries the artwork with the folded spine lying
       on top of it, because that is how the paper folds - so the running order
       on the right is as wide as the spine and the artwork put together. */
    grid-template-columns: 50% 50%;
    margin-top: 18px;
    background: var(--paper);
    border-radius: 2px;
    box-shadow:
        0 1px 1px rgba(60, 50, 30, .2),
        0 10px 22px rgba(60, 50, 30, .18);
    color: var(--paper-ink);
    /* visible, so the lifted flap can sit proud of the sheet's top edge -
       the panels inside do their own clipping */
    overflow: visible;
}

/* ---- the folded half ---- */

.inlay__fold {
    position: relative;
}

/* The flap: lifted 9px up and 9px right off the sheet's corner. It runs the
   full height of the fold, so shifting it up leaves the same 9px of artwork
   showing at the bottom as it overhangs at the top - which is what keeps the
   two ends of the slope beside it at the same angle. What joins it back to the
   sheet is that slope on its left - see .inlay__fold::before. */
.inlay__spine {
    position: absolute;
    left: 0;
    top: 0;
    bottom: 0;
    width: var(--spine-w);
    display: flex;
    align-items: center;
    justify-content: center;
    padding: 14px 0;
    background: var(--album);
    transform: translate(9px, -9px);
    box-shadow: 0 8px 13px -7px rgba(50, 40, 20, .5);
    z-index: 2;
}

/* The slope that joins the flap back to the sheet. The flap is lifted up and
   right, which opens a gap down its left-hand side; this fills it, running from
   the flap's two left corners to the two left corners of the artwork panel
   underneath. Darker because the slope faces away from the light. */
.inlay__fold::before {
    content: "";
    position: absolute;
    left: 0;
    top: -9px;
    bottom: 0;
    /* 10px, not 9: the flap starts at x 9, so this runs a pixel underneath it.
       Meeting it exactly leaves a hairline where neither one paints and the
       artwork shows through - obvious against a light cover. */
    width: 10px;
    background-color: var(--album);
    filter: brightness(.66);
    /* still 9px down at each end, so both slopes sit at the same angle */
    clip-path: polygon(
        10px 0,                     /* under the flap's top left corner */
        0 9px,                      /* the artwork's top left corner */
        0 100%,                     /* the artwork's bottom left corner */
        10px calc(100% - 9px)       /* under the flap's bottom left corner */
    );
    z-index: 1;
}

.inlay__spineText {
    writing-mode: vertical-rl;
    transform: rotate(180deg);      /* so it reads upwards, as on the shelf */
    white-space: nowrap;
    overflow: hidden;
    text-overflow: ellipsis;
    max-height: 100%;
    font-family: Oswald, sans-serif;
    font-weight: 500;
    font-size: .92rem;
    letter-spacing: .16em;
    text-transform: uppercase;
    color: var(--album-ink, #fff);
    text-shadow: var(--album-ink-shadow, 0 1px 2px rgba(0, 0, 0, .3));
}

/* ---- the cover, and the words over it ---- */

.inlay__face {
    position: relative;
    height: var(--sheet-h);         /* the running order sets the height now */
    overflow: hidden;
    /* The tape's own colour, which is what the fold lying over it is printed
       in - so while the cover is arriving the panel and the album name are one
       colour instead of paper meeting ink. It was --paper-edge, and the join
       between the two was the first thing you saw. */
    background: var(--album);
}


/* Held back until all of it is in, for the same reason as .box__cover: this is
   the same cover drawn bigger - so a bigger copy of it, and a longer wait - and
   watching it paint itself a row at a time over the panel is worse than the flat
   colour it is covering. */
.inlay__art {
    position: absolute;
    inset: 0;
    width: 100%;
    height: 100%;
    /* Fills the panel, always - no band of the tape's colour beside it. The
       panel is wider than it is tall on a desk, so a square cover is scaled
       to the width and loses about 4% off the top and the same off the
       bottom. That crop is the cost of filling it and it is deliberate: keep
       anything that must be read away from the top and bottom edges of a
       cover, the way the rack's crop already asks. */
    object-fit: cover;
    display: block;
    opacity: 0;
    transition: opacity var(--art-fade) ease;
}

.inlay__art.is-in { opacity: 1; }

@media (prefers-reduced-motion: reduce) {
    .inlay__art { transition: none; }
}

/* these two set display themselves, so they have to be told to obey [hidden] */
.inlay__art[hidden],
.inlay__plain[hidden] { display: none; }

/* stands in until a tape has its own cover */
.inlay__plain {
    position: absolute;
    inset: 0;
    display: flex;
    align-items: center;
    justify-content: center;
    margin: 0;
    padding: 12px;
    text-align: center;
    /* the tape's colour, flat - the diagonal hatching that used to say "no
       cover" was just noise, and the panel is this colour anyway now */
    background: var(--album);
    color: #fff;
    font-family: Oswald, sans-serif;
    font-size: 1.05rem;
    letter-spacing: .18em;
    text-transform: uppercase;
    text-shadow: 0 1px 3px rgba(0, 0, 0, .35);
}

.inlay__lyrics {
    position: absolute;
    /* below the tab strip, which is the top edge of the same sheet */
    inset: var(--sides-h) 0 0 0;
    /* Clear of the flap, which is shifted --spine-lift right - so its right
       edge is there and the words start --lyric-clear past that. */
    padding: 12px var(--lyric-pad) 14px
             calc(var(--spine-w) + var(--spine-lift) + var(--lyric-clear));
    overflow-y: auto;
    /* auto, and it must stay auto - `contain` is what a scrolling panel inside a
       page usually wants and it is wrong here. It stops the scroll passing to
       the page when this panel has no more to give, so on a phone a finger
       landing on the words or on the running order pins the whole page: worst
       on a short tape, where there is nothing to scroll and the touch does
       nothing at all. auto is the browser's own behaviour - scroll this, and
       when it reaches the end in the direction being scrolled, the page carries
       on from there. */
    overscroll-behavior: auto;
    /* the cover stays readable underneath, the way words printed on a
       translucent sheet would */
    background: var(--lyric-paper);
    backdrop-filter: blur(2px);
    -webkit-backdrop-filter: blur(2px);
    white-space: pre-wrap;
    font-size: var(--lyric-size);
    /* Tight, deliberately: the lines of a verse belong together, and they have
       to read as closer to each other than the first of them is to the heading
       above it. See --lyric-lead on .lyric__h2. */
    line-height: 1.42;
    opacity: 0;
    visibility: hidden;
    transition: opacity .25s ease, visibility .25s;
}

/* ------------------------------------------------------------------ *
 * what a lyric file is allowed to say
 *
 * The sheet is now built line by line rather than dropped in as one run of
 * text, so each line is its own element and the spacing is margins rather
 * than newlines. pre-wrap stays: a chord line is aligned with spaces and
 * they have to survive.
 * ------------------------------------------------------------------ */

.lyric__line {
    margin: 0;
    /* A line too long for the paper continues indented, so it reads as one
       line that ran on rather than as two lines of the song. */
    padding-left: var(--lyric-hang);
    text-indent: calc(-1 * var(--lyric-hang));
}

/* The blank line between two verses. A margin rather than an empty line of
   type, so the gap does not change size with the font. */
.lyric__gap {
    margin: 0;
    height: var(--lyric-gap);
}

/* # and ## - the only centring there is, which is what a song title and a
   "Chorus" want and nothing else does. */
.lyric__h1,
.lyric__h2 {
    margin: 0;
    font-family: Oswald, sans-serif;
    font-weight: 600;
    line-height: 1.28;
}

/* The song's own name, over the middle of the sheet like a title on a card. */
.lyric__h1 {
    text-align: center;
    font-size: 1.15em;
    letter-spacing: .02em;
    padding-bottom: var(--lyric-lead);
}

/* A section - a verse, the chorus. Left, with the words it belongs to, rather
   than centred: it is a label on the verse under it, not a title for the page. */
.lyric__h2 {
    font-size: .95em;
    letter-spacing: .12em;
    text-transform: uppercase;
    color: var(--album-on-paper, #6b6153);
    padding-top: .9em;
    /* More room under it than the verse leaves between its own lines, so the
       heading reads as the top of the verse rather than as a line in it. */
    padding-bottom: var(--lyric-lead);
}

/* The blank line *after* a heading is spacing counted twice - the heading is
   already the top of what follows it - and collapsing it is most of what made
   the gaps stop yawning. The blank line *before* one is kept: that is the end
   of the verse above, and it is the only thing separating two sections. */
.lyric__h1 + .lyric__gap,
.lyric__h2 + .lyric__gap { height: 0; }

/* A chord line and the line it sits over, in one monospace metric so the
   columns he typed still land where he put them. Nothing else on the sheet
   is monospace - a song with no chords never sees this. */
.lyric__chords,
.lyric__line--under {
    margin: 0;
    font-family: ui-monospace, "Cascadia Mono", Consolas, "DejaVu Sans Mono", monospace;
    font-size: .86em;
}

.lyric__chords {
    color: var(--album-on-paper, #6b6153);
    font-weight: 700;
    line-height: 1.35;
    /* it belongs to the line underneath, so it sits closer to it than to the
       line above - the pair reads as one thing */
    margin-top: var(--lyric-gap);
}

.lyric__line--under { line-height: 1.45; }

/* The first thing on the sheet never wants a gap above it. */
.inlay__lyrics > :first-child { margin-top: 0; }

/* ------------------------------------------------------------------ *
 * cover or words
 *
 * The same two-tab strip the running order uses, on the other half of the
 * sheet. The words cover the artwork almost completely - which is what a
 * printed inlay does too - so there has to be a way back to the picture.
 *
 * It starts where the flap ends rather than spanning the panel, because the
 * folded spine is painted over this corner and would lie across the first
 * tab. It is only in the document at all when the song has words: a tab that
 * does nothing is worse than no tab, and a song with no lyrics keeps every
 * pixel of its cover.
 * ------------------------------------------------------------------ */

.faces {
    position: absolute;
    left: calc(var(--spine-w) + var(--spine-lift));
    right: 0;
    top: 0;
    height: var(--sides-h);
    display: flex;
    /* over the words, which are the same paper and start directly below */
    z-index: 3;
    /* The sheet's own paper, opaque - not the translucent stuff the words are
       printed on. The running order's tabs sit on solid paper, and a strip
       that let the cover through would make one half of the same row of tabs
       look like a different material from the other. */
    background: var(--paper);
}

/* The scrollbars belong to the paper, not to the browser: a thumb in the
   paper's own mid tone floating on a track you cannot see. */
.inlay__lyrics,
.songs {
    scrollbar-width: thin;
    scrollbar-color: #bfb49c transparent;
}

.inlay__lyrics::-webkit-scrollbar,
.songs::-webkit-scrollbar {
    width: 11px;
}

.inlay__lyrics::-webkit-scrollbar-track,
.songs::-webkit-scrollbar-track {
    background: transparent;
}

.inlay__lyrics::-webkit-scrollbar-thumb,
.songs::-webkit-scrollbar-thumb {
    background: #bfb49c;
    border: 3px solid transparent;      /* inset, so it reads as a slim pill */
    background-clip: padding-box;
    border-radius: 6px;
}

.inlay__lyrics::-webkit-scrollbar-thumb:hover,
.songs::-webkit-scrollbar-thumb:hover {
    background: #a89b80;
    background-clip: padding-box;
}

/* only a song that has words covers its cover */
.inlay__face--words .inlay__lyrics { opacity: 1; visibility: visible; }
.inlay__lyrics:focus-visible {
    outline: 2px solid var(--album-on-paper, var(--album));
    outline-offset: -3px;
}

/* ---- the running order ---- */

/* No height of its own: both children are absolute, so the sheet's height comes
   from the square cover panel beside it and a long tape scrolls in the list
   instead of stretching the paper. */
.inlay__list {
    position: relative;
    min-width: 0;
}

.sides {
    position: absolute;
    left: 0;
    right: 0;
    top: 0;
    height: var(--sides-h);
    display: flex;
}

.sides__tab {
    flex: 1 1 0;
    padding: 0;
    border: 0;
    border-right: 1px solid var(--paper-rule);
    background: transparent;
    color: #9a917c;
    font-family: Oswald, sans-serif;
    font-size: .66rem;
    letter-spacing: .26em;
    text-transform: uppercase;
    cursor: pointer;
}

.sides__tab:last-child { border-right: 0; }

/* The rule under the strip is drawn by the tabs, not by the strip. On the
   strip it was painted below them, so the selected tab's coloured underline
   could never cover it and left a hairline of paper-rule under the colour -
   obvious on the cover half, where it ran across the artwork. A tab that is
   not selected carries it; the selected one has its own colour there instead
   and nothing underneath. */
.sides__tab:not([aria-selected="true"]) {
    box-shadow: inset 0 -1px 0 var(--paper-rule);
}
@media (hover: hover) {
    .sides__tab:hover:not([disabled]) { background: rgba(0, 0, 0, .03); }
}
.sides__tab:focus-visible {
    outline: 2px solid var(--album-on-paper, var(--album));
    outline-offset: -3px;
}

/* the side being looked at is the sheet itself; the other is a tab to turn to */
.sides__tab[aria-selected="true"] {
    background: rgba(0, 0, 0, .05);
    color: var(--paper-ink);
    box-shadow: inset 0 -2px 0 var(--album-on-paper, var(--album));
}

/* a tape with nothing on side B does not offer one */
.sides__tab[disabled] {
    cursor: default;
    opacity: .38;
}

.songs {
    position: absolute;
    left: 0;
    right: 0;
    top: var(--sides-h);
    bottom: 0;
    margin: 0;
    /* Tight, because this is what decides how much of the running order you see
       at once - the sheet is as tall as the square cover beside it and cannot
       grow. See .song. */
    padding: var(--songs-pad) 0;
    list-style: none;
    overflow-y: auto;
    /* auto, and it must stay auto - `contain` is what a scrolling panel inside a
       page usually wants and it is wrong here. It stops the scroll passing to
       the page when this panel has no more to give, so on a phone a finger
       landing on the words or on the running order pins the whole page: worst
       on a short tape, where there is nothing to scroll and the touch does
       nothing at all. auto is the browser's own behaviour - scroll this, and
       when it reaches the end in the direction being scrolled, the page carries
       on from there. */
    overscroll-behavior: auto;
}

/* The row is the line on the paper; the button and the download sit side by
   side in it, which is why the rule underneath moved here off the button. */
.songs li {
    display: flex;
    align-items: stretch;
    /* Stated, rather than left to the text. It was 27.6px because that is what
       .88rem plus 5px of padding happens to measure, so every sum about how
       many songs fit was really a measurement of that accident. A title is one
       line with an ellipsis, so a row can never need more. */
    height: var(--song-row);
    border-bottom: 1px solid var(--paper-rule);
}

.songs li:last-child { border-bottom: 0; }

.song {
    display: flex;
    align-items: baseline;
    gap: 9px;
    flex: 1 1 auto;
    min-width: 0;
    /* 5px, not 7. The list is 266px tall on a 600px deck and a row was 31.6px,
       so eight and a half songs showed and the ninth was always half under the
       edge with a scrollbar beside it. At 5 a row is 27.6 and nine fit clear. */
    padding: 5px 6px 5px 16px;
    border: 0;
    background: transparent;
    color: inherit;
    font: inherit;
    font-size: .88rem;
    text-align: left;
    cursor: pointer;
}

/* Taking the song away with you. Quiet until you go looking for it - this is
   a page for listening, and a row of arrows down the paper would read as a
   download list rather than a running order. */
.song__get {
    flex: none;
    display: flex;
    align-items: center;
    padding: 0 14px 0 8px;
    color: #b7ae98;
    transition: color .15s ease;
}

.song__get svg {
    width: 15px;
    height: 15px;
    fill: none;
    stroke: currentColor;
    stroke-width: 1.9;
    stroke-linecap: round;
    stroke-linejoin: round;
}

@media (hover: hover) {
    .song__get:hover { color: var(--paper-ink); }
}

.song__get:focus-visible {
    outline: 2px solid var(--album-on-paper, #6b7178);
    outline-offset: -2px;
    border-radius: 2px;
}

.song__n {
    flex: none;
    width: 1.35em;
    font-variant-numeric: tabular-nums;
    color: #9a917c;
}

.song__title {
    flex: 1 1 auto;
    min-width: 0;
    white-space: nowrap;
    overflow: hidden;
    text-overflow: ellipsis;
}

.song__time {
    flex: none;
    font-variant-numeric: tabular-nums;
    font-size: .82rem;
    color: #9a917c;
}

@media (hover: hover) {
    .song:hover { background: rgba(0, 0, 0, .04); }
}
.song:focus-visible {
    outline: 2px solid var(--album-on-paper, var(--album));
    outline-offset: -2px;
}

.song--playing {
    background: rgba(0, 0, 0, .05);
    font-weight: 600;
}

.song--playing .song__n {
    color: var(--album-on-paper, var(--album));
}

.song--playing .song__title::after {
    content: "";
    display: inline-block;
    width: .5em;
    height: .5em;
    margin-left: .5em;
    border-radius: 50%;
    background: var(--album-on-paper, var(--album));
    vertical-align: baseline;
}

/* ---- putting the mixtape in order ---- */

/* The song being carried has left its line on the paper, so the line fades
   while it is away; the copy under the pointer is the song itself. */
.songs--dragging {
    user-select: none;
    -webkit-user-select: none;
}

.songs li.is-lifted { opacity: .35; }

/* Where the song will land. Drawn across the rule between two rows, so it is
   pulled up by half its own thickness and the rule's one pixel to sit on it. */
.songs .songs__drop {
    position: absolute;
    left: 12px;
    right: 12px;
    height: 0;
    margin-top: calc(-.5px - var(--drop-line) / 2);
    border: 0;
    border-top: var(--drop-line) solid var(--album-on-paper, var(--album));
    pointer-events: none;
}

.songs .songs__drop[hidden] { display: none; }

/* The copy that follows the pointer. On <body>, not in the list, because the
   inlay carries a transform and a fixed box inside it would be pinned to the
   sheet instead of the screen - so it names the paper's own colours itself. */
.song-ghost {
    position: fixed;
    left: 0;
    top: 0;
    z-index: 40;
    display: flex;
    align-items: stretch;
    background: var(--paper);
    color: var(--paper-ink);
    opacity: var(--drag-ghost);
    border-radius: 2px;
    box-shadow:
        0 1px 2px rgba(60, 50, 30, .25),
        0 6px 14px rgba(60, 50, 30, .22);
    pointer-events: none;
    will-change: transform;
}

.is-dragging-song,
.is-dragging-song * { cursor: grabbing; }

/* ------------------------------------------------------------------ *
 * the statistics sheet - /ms
 *
 * The inlay again, given a quarter turn: the flap that carries the album name
 * down the side of the player's sheet lies across the top of this one, lifted
 * off the paper the same 9px and with the same slope joining it back on. A
 * table of forty songs wants the width more than the page wants to look like a
 * cassette insert.
 *
 * It reuses the paper, the type and the rules rather than restating them, so
 * the two sheets cannot drift apart - only the layout below is new.
 * ------------------------------------------------------------------ */

.stats {
    max-width: var(--inlay-w);
    margin: 0 auto;
    padding: 28px 16px 48px;
}

.stats__sheet {
    display: block;             /* not the player's two-column grid */
    width: auto;
    max-width: none;
    margin: 0;
    transform: none;
    position: relative;
    padding-top: calc(var(--spine-h) + var(--spine-lift));
}

/* The flap, folded over the top edge.
   
   It moves the way both of the player's do: --spine-lift *away from the
   crease*, which puts it down into the sheet here, and --spine-lift *along*
   the crease, towards the light. On the desktop inlay the crease is the left
   edge so that reads as right-and-up; on a phone it is the bottom edge and
   reads as up-and-right; here the crease is the top, so it is down-and-right.
   Same paper, same fold, turned. */
.stats__fold {
    position: absolute;
    left: 0;
    right: 0;
    top: 0;
    height: var(--spine-h);
    display: flex;
    align-items: center;
    justify-content: center;
    background: var(--album);
    transform: translate(var(--spine-lift), var(--spine-lift));
    box-shadow: 0 8px 13px -7px rgba(50, 40, 20, .5);
    z-index: 2;
}

/* The slope that joins the flap back to the sheet: it belongs at the crease,
   which is the outer edge the paper bends over - so above the flap, not below
   it. Below is only where the flap has moved from, and filling that would
   draw the fold on the wrong side of itself.

   It reaches --spine-lift past the right, because the flap is that far right
   and this has to meet its other corner. Darker, because the slope faces away
   from the light. The player's own inlay is this shape rotated. */
.stats__sheet::before {
    content: "";
    position: absolute;
    left: 0;
    right: calc(-1 * var(--spine-lift));
    top: 0;
    height: 10px;
    background-color: var(--album);
    filter: brightness(.66);
    /* 10px, not --spine-lift: it runs a pixel under the flap so no hairline of
       paper shows between the two - the same reason the player's is 10 wide. */
    clip-path: polygon(
        0 0,                                        /* the sheet's top left */
        calc(100% - var(--spine-lift)) 0,           /* the sheet's top right */
        100% 10px,                                  /* under the flap's top right */
        var(--spine-lift) 10px                      /* under the flap's top left */
    );
    z-index: 1;
}

.stats__foldText {
    font-family: Oswald, sans-serif;
    font-weight: 500;
    font-size: 1rem;
    letter-spacing: .22em;
    text-transform: uppercase;
    color: var(--album-ink, #fff);
    text-shadow: var(--album-ink-shadow, 0 1px 2px rgba(0, 0, 0, .3));
}

.stats__body { padding: 22px 20px 20px; }

/* ---- the running order, as a table ---- */

.stats__table {
    width: 100%;
    border-collapse: collapse;
    font-size: .86rem;
}

.stats__table thead th {
    position: sticky;           /* forty rows is more than a screen */
    top: 0;
    background: var(--paper);
    padding: 0 8px 5px;
    border-bottom: 1px solid var(--paper-rule);
    text-align: center;
    white-space: nowrap;
}

.stats__table thead .stats__song { text-align: left; }
/* over the figures it labels, which are right-aligned in their own column */
.stats__table thead .stats__num  { text-align: right; }

/* How many people, as opposed to how much listening - which is why it is a line
   of its own and not a cell under Unique. That column adds up to listener-songs
   and this does not belong at the foot of it. */
.stats__listeners {
    margin: 0 0 14px;
    font-family: Oswald, sans-serif;
    font-size: .66rem;
    letter-spacing: .18em;
    text-transform: uppercase;
    color: #9a917c;
}

.stats__listeners strong {
    font-size: 1.15em;
    color: var(--album-on-paper, var(--album));
}

/* The heading is the control. A button rather than a click handler on the cell,
   so it is reachable by keyboard and announces itself as something to press
   without any of that having to be described in aria. */
.stats__sort {
    padding: 0;
    border: 0;
    background: none;
    font: inherit;
    font-family: Oswald, sans-serif;
    font-weight: 500;
    font-size: .62rem;
    letter-spacing: .18em;
    text-transform: uppercase;
    color: #9a917c;
    cursor: pointer;
}

@media (hover: hover) {
    .stats__sort:hover { color: var(--paper-ink); }
}

.stats__sort:focus-visible {
    outline: 2px solid var(--album-on-paper, var(--album));
    outline-offset: 2px;
}

/* Which way it is sorted, said with the arrow rather than only in aria-sort.
   The column being sorted on is also the only one in the paper's own ink. */
.stats__table th[aria-sort="ascending"] .stats__sort,
.stats__table th[aria-sort="descending"] .stats__sort { color: var(--paper-ink); }

.stats__table th[aria-sort="ascending"] .stats__sort::after  { content: " ↑"; }
.stats__table th[aria-sort="descending"] .stats__sort::after { content: " ↓"; }

.stats__table td {
    /* Tight. Forty songs and three numbers each is a table to scan, not to
       read, and the row height is what decides how much of it is on screen. */
    padding: 3px 8px;
    border-bottom: 1px solid var(--paper-rule);
    vertical-align: middle;
}

.stats__table tbody tr:last-child td { border-bottom: 0; }

/* The column sums. A rule above it rather than below, the way a total is ruled
   off on paper, and set in the tape's own ink so it reads as the answer rather
   than as one more row of the list. */
.stats__table tfoot th,
.stats__table tfoot td {
    padding: 7px 8px 0;
    border-top: 2px solid var(--paper-rule);
    border-bottom: 0;
    font-family: Oswald, sans-serif;
    font-weight: 600;
    color: var(--album-on-paper, var(--album));
}

/* Right, hard against the figures it belongs to, rather than out at the far
   side of the sheet where nothing connects it to them. */
.stats__table tfoot .stats__song {
    text-align: right;
    font-size: .62rem;
    letter-spacing: .18em;
    text-transform: uppercase;
}

@media (hover: hover) {
    .stats__table tbody tr:hover td { background: rgba(0, 0, 0, .028); }
}

.stats__num {
    width: 2.6em;
    text-align: right;
    font-family: Oswald, sans-serif;
    color: #9a917c;
}

/* The first three are the ones worth seeing at a glance, the way a chart puts
   its winners in bold. Everything below reads as the long tail it is.

   Only rows that have actually been played: on a young chart the top three are
   whatever sorted first, and setting a song nobody has heard in bold because it
   happens to be third from the top is worse than not marking anything. And only
   while the table is still a chart - sorted by name, "the first three" means
   nothing, so the JS takes --ranked off. */
.stats__table--ranked tbody tr.stats__row--heard:nth-child(-n + 3) .stats__num,
.stats__table--ranked tbody tr.stats__row--heard:nth-child(-n + 3) .stats__title {
    color: var(--album-on-paper, var(--album));
    font-weight: 600;
}

.stats__song { text-align: left; }

.stats__title {
    display: block;
    line-height: 1.25;
}

/* on /mt the name is the link that opens the tape */
a.stats__title {
    color: inherit;
    text-decoration: none;
    border-bottom: 1px solid var(--paper-rule);
    display: inline-block;
}

@media (hover: hover) {
    a.stats__title:hover { border-bottom-color: var(--album-on-paper, var(--album)); }
}

/* The tape it came off, under the name. Small and quiet: it is context, not
   the thing being read, and on a row this tight it has to earn its pixels. */
.stats__tape {
    display: block;
    font-size: .66rem;
    line-height: 1.2;
    color: #9a917c;
}

.stats__stat {
    width: 5.2em;
    text-align: center;
    font-variant-numeric: tabular-nums;      /* columns of figures line up */
    font-feature-settings: "tnum";
    color: var(--paper-ink);
}

/* Plays is the column the table is sorted on, so it is the one that reads. */
.stats__stat--lead {
    font-family: Oswald, sans-serif;
    font-weight: 600;
}

/* Nobody has played it yet. Still listed - a nought is an answer. */
.stats__row--silent .stats__title,
.stats__row--silent .stats__stat { color: #a9a08c; }

.stats__note,
.stats__back {
    margin: 18px 0 0;
    font-size: .74rem;
    line-height: 1.5;
    color: #8a8270;
}

.stats__note code {
    font-size: .95em;
    color: var(--album-on-paper, var(--album));
}

.stats__back a { color: var(--album-on-paper, var(--album)); }

@media (max-width: 620px) {
    .stats { padding: 16px 10px 32px; }
    .stats__body { padding: 16px 10px 12px; }
    .stats__table { font-size: .8rem; }
    .stats__table td,
    .stats__table thead th { padding-left: 5px; padding-right: 5px; }
    .stats__stat { width: 3.6em; }
    .stats__num { width: 2em; }

    /* The tape a song is on is the first thing to go when there is no room -
       the song's own name is what is being read. */
    .stats__tape { display: none; }
}

/* ------------------------------------------------------------------ *
 * setup help (only while there is no music)
 * ------------------------------------------------------------------ */

.setup {
    max-width: 600px;
    margin: 34px auto 0;
    padding: 22px 24px;
    border-radius: var(--radius);
    background: var(--card);
    border: 1px solid rgba(255, 255, 255, .8);
    line-height: 1.6;
}

.setup h2 {
    margin: 0 0 10px;
    font-family: Oswald, sans-serif;
    font-weight: 600;
    letter-spacing: .04em;
}

.setup pre,
.setup code {
    font-family: ui-monospace, "SFMono-Regular", Menlo, Consolas, monospace;
    font-size: .85rem;
}

.setup pre {
    padding: 14px 16px;
    border-radius: 8px;
    background: rgba(0, 0, 0, .05);
    overflow-x: auto;
}

.colophon {
    margin: 44px auto 0;
    text-align: center;
    font-family: Oswald, sans-serif;
    font-size: .72rem;
    letter-spacing: .18em;
    color: #a8acb1;
}

/* The way to the games: a key in the same brushed face as the links below,
   with a word on it because it goes somewhere on this site rather than off
   it. Under the shelf on a desktop, in the colophon on a phone - see
   index.php for why there are two. */
.games-link {
    display: block;
    width: 100%;
    padding: 9px 12px;
    border-radius: 4px;
    background: linear-gradient(180deg, #fbfbfc, #e6e8ea 62%, #dcdfe2);
    box-shadow:
        0 1px 0 rgba(255, 255, 255, .9) inset,
        0 -1px 0 rgba(0, 0, 0, .07) inset,
        0 1px 2px rgba(20, 25, 30, .22);
    color: #6d737a;
    font: 500 .7rem/1 Oswald, sans-serif;
    letter-spacing: .18em;
    text-transform: uppercase;
    text-align: center;
    text-decoration: none;
    transition: transform .12s ease, box-shadow .12s ease, color .12s ease;
}
.games-link--rack { margin-top: 6px; }
.games-link--foot { width: auto; display: inline-block; margin: 0 0 14px; }
@media (hover: hover) {
    .games-link:hover {
        color: #2c3138;
        box-shadow:
            0 1px 0 rgba(255, 255, 255, .9) inset,
            0 -1px 0 rgba(0, 0, 0, .07) inset,
            0 2px 5px rgba(20, 25, 30, .28);
    }
}
.games-link:active { transform: translateY(1px); box-shadow: 0 1px 0 rgba(255, 255, 255, .9) inset, 0 0 1px rgba(20, 25, 30, .3); }
.games-link:focus-visible { outline: 2px solid #b3392c; outline-offset: 2px; }
@media (min-width: 1041px) { .games-link--foot { display: none; } }
@media (max-width: 1040px) { .games-link--rack { display: none; } }

/* Where else to find him. Keys, because this page already has a language for
   something you press and inventing a second one for the footer would be a
   different page's idea - the same brushed face and the same travel as the
   transport, at the size a footer can carry. */
.colophon__links {
    display: flex;
    flex-wrap: wrap;
    justify-content: center;
    gap: 8px;
    margin: 0 0 16px;
}

.colophon__link {
    display: grid;
    place-items: center;
    /* square, because what is in them is now a mark rather than a word */
    width: 38px;
    height: 34px;
    padding: 0;
    border-radius: 4px;
    background: linear-gradient(180deg, #fbfbfc, #e6e8ea 62%, #dcdfe2);
    box-shadow:
        0 1px 0 rgba(255, 255, 255, .9) inset,
        0 -1px 0 rgba(0, 0, 0, .07) inset,
        0 1px 2px rgba(20, 25, 30, .22);
    color: #6d737a;
    font-size: .66rem;
    letter-spacing: .16em;
    text-transform: uppercase;
    text-decoration: none;
    transition: transform .12s ease, box-shadow .12s ease, color .12s ease;
}

@media (hover: hover) {
    .colophon__link:hover {
        color: var(--ink);
        transform: translateY(-1px);
        box-shadow:
            0 1px 0 rgba(255, 255, 255, .9) inset,
            0 -1px 0 rgba(0, 0, 0, .07) inset,
            0 3px 6px rgba(20, 25, 30, .26);
    }
}

/* Pressed, the way the transport keys are: down onto the page, not up off it. */
.colophon__link:active {
    transform: translateY(1px);
    box-shadow:
        0 1px 2px rgba(20, 25, 30, .3) inset,
        0 1px 0 rgba(255, 255, 255, .7);
}

.colophon__link:focus-visible {
    outline: 2px solid #8b9098;
    outline-offset: 2px;
}

/* currentColor, so a mark is the key's own ink and lifts with it on hover -
   the brands' colours would be five loud spots under a page this quiet. */
.colophon__icon {
    width: 17px;
    height: 17px;
    fill: currentColor;
    display: block;
}

/* ------------------------------------------------------------------ *
 * narrow screens: the rack becomes a shelf under the player
 * ------------------------------------------------------------------ */

/* Narrow screens: there is no room for the rack beside the deck, and putting it
   under everything buried it below the inlay. So the shelf keeps its place on
   the left of the cassette and is pushed off the side of the screen, with only
   a sliver of the tapes showing. The tab pulls it out; picking a tape, or
   touching anything else, pushes it back. */
@media (max-width: 1040px) {
    /* A row above the deck, not a drawer off the side of it. The boxes lean on
       each other exactly as they do on the shelf, only sideways: each covers the
       right of the one before, so the row reads as cases pushed together in a
       drawer and the last one is whole.

       The drawer this replaced hid the tapes behind a 24px tab that had to be
       found before anything could be chosen, which on a phone is the one thing
       you want in front of you. */
    .rack {
        order: 0;                  /* .player is order 1, so this lands above it */
        /* room above for the loaded tape to stand up into without closing on
           the tagline - it lifts 14px, and its shadow goes a little further */
        margin: 20px 0 16px;
    }

    /* nothing to pull out any more */
    .rack__pull { display: none; }

    .rack__stack {
        flex-direction: row;
        /* room above for the loaded tape to stand in - it rises --box-rise and
           there is nothing above the row to lend it the space */
        padding-top: var(--box-rise);
        /* Hung from one line at the top. The boxes are not all the same size -
           see below - so this puts all the step in their bottoms and keeps their
           name bands level, which is the edge you actually read along.
           Centring tapers the row at both ends and bottom-aligning reads as
           cases standing on a shelf; both were tried. */
        align-items: flex-start;
        padding-bottom: 6px;
    }

    /* flex + a negative margin lets them share the row however many there are:
       five boxes and four -16% overlaps settle at about a third each, so half
       of every cover shows. A sixth tape narrows them all to suit rather than
       running off the edge. */
    .box {
        flex-grow: 1;
        flex-shrink: 1;
        flex-basis: 0;
        width: auto;
        pointer-events: auto;
        /* Light: every box casts one onto the box behind it, and with this much
           overlap they stack up. What looks right alone is far too heavy five
           deep, so this is much softer than the shelf's. */
        filter: drop-shadow(2px 3px 6px rgba(0, 0, 0, .17));
    }

    /* The boxes were a ladder here - each one a shade smaller than the one in
       front of it - because the old drawn box had no depth of its own, and a
       row of identical flat rectangles overlapping in a straight line read as
       cases pushed *inside* each other. Faking the perspective with size was
       the fix for that.

       The photographed case has its own perspective in the picture, so the
       trick is not needed any more and Edvards asked for it gone: every box is
       the same size here, as on the shelf. If a row of them ever reads as
       flat again, the honest fix is the artwork, not flex-grow.

       (The `flex-grow: 1` they all share is up in .box above.) */

    .box + .box {
        margin-top: 0;
        margin-left: -16%;
    }

    /* Past six - the albums, the mixtape and a couple of tapes off other
       people's links - sharing the row would slice them too thin to read, so
       the row scrolls sideways instead and each box keeps a width. Six or
       fewer never scroll. The overlap and the z-order are exactly as above.
       No scrollbar is drawn: a finger does not need one and it was cutting
       the boxes' shadows off; --box-shade-room below the row is for those. */
    .rack__stack--scrolls {
        overflow-x: auto;
        overflow-y: hidden;
        padding-bottom: var(--box-shade-room);
        scrollbar-width: none;
        -webkit-overflow-scrolling: touch;
    }

    .rack__stack--scrolls::-webkit-scrollbar { display: none; }

    .rack__stack--scrolls .box {
        flex: 0 0 var(--box-many-w);
    }

    /* Sideways a box cannot slide out to the right to show itself - that is
       where the next one is - so it comes up instead. The z-order stays exactly
       as it is: later boxes always paint over earlier ones, here as on the
       shelf, so a chosen tape that is not the last stays partly under its
       neighbour. That is the shelf being a shelf, not a bug to paper over.

       The loaded tape always stands highest. Nothing you are only pointing at
       should ever rise above the one actually in the deck.

       There is no hover here at all. This layout is for touch, where a finger
       has no hover to give - and a narrow window on a desktop gets the touch
       behaviour rather than a second, half-applicable one. */
    .box:focus-visible .box__body { transform: translateY(calc(-1 * var(--box-rise-near))); }

    .box[aria-selected="true"] .box__body,
    .box[aria-selected="true"]:focus-visible .box__body {
        transform: translateY(calc(-1 * var(--box-rise)));
    }
}

@media (max-width: 620px) {
    .transport { width: 100%; }
    .shells__acts { right: 0; }
    .shells__tint { left: var(--tint-ring); }
    .key { width: auto; flex: 1 1 0; }
    .key--play { flex: 1.5 1 0; }

    /* too narrow to fold side by side: the two halves stack down the page */
    .inlay { grid-template-columns: 1fr; }

    /* On a phone the running order is what you came for, so it goes first and
       the cover follows it - scrolling past a square of album art to reach the
       song you want is a lot of thumb. So: list, then the cover with the name
       folded over it. */
    .inlay__list { order: 1; }
    .inlay__fold { order: 2; }
    /* Stacked, the flap is along the bottom rather than down the left, so the
       words need no room on the left and room underneath instead - otherwise
       the last line of a song is printed behind the album name.

       It has to clear the flap's height *and* its lift, which is the bit that
       was missed: --sides-h happened to be near the flap's height and was used
       as a stand-in for it, which left the words 7px short and the last line
       still tucked behind the name. Named parts now, and --lyric-pad of air
       above it so it matches the margin down the sides. */
    .inlay__lyrics {
        padding: 12px var(--lyric-pad)
                 calc(var(--spine-h) + var(--spine-lift) + var(--lyric-pad));
    }
    .faces { left: 0; }

    /* Stacked, the cover is the full width of the page and square again - the
       running order has its own height below it, so nothing is driven off this.
       Square panel, square art: nothing is cropped here. */
    .inlay__face {
        height: auto;
        aspect-ratio: 1;
    }
    /* No room to step outside anything on a phone. */
    .inlay {
        width: auto;
        max-width: none;
        margin-left: 0;
        transform: none;
    }

    /* The flap turns with the page, and it stays at the END of the paper. The
       inlay is one strip - running order, cover, spine - so stacked it reads
       down the page in that order and the fold is the last thing on it, along
       the bottom of the artwork. Putting it above the cover made it the middle
       of the strip, which is a fold that cannot exist.

       Everything here is the desktop construction given a quarter turn: the
       crease is the sheet's bottom edge, the flap is lifted 9px off it - up,
       away from the crease, and right, towards the light - and the slope that
       joins it back to the paper is the strip below it rather than beside it. */
    .inlay__spine {
        left: 0;
        right: 0;
        top: auto;
        bottom: 0;
        width: auto;
        padding: 9px 14px;
        /* centred, as the desktop flap is - the base rule already does it */
        transform: translate(9px, -9px);
    }

    .inlay__fold::before {
        left: 0;
        /* the flap is 9px right of the sheet, so the slope has to reach that
           far past it to meet the flap's other corner */
        right: -9px;
        top: auto;
        bottom: 0;
        width: auto;
        /* 10px, not 9, for the same reason the desktop one is 10 wide: it runs
           a pixel underneath the flap so no hairline of artwork shows between */
        height: 10px;
        clip-path: polygon(
            9px 0,                      /* under the flap's bottom left corner */
            0 10px,                     /* the artwork's bottom left corner */
            calc(100% - 9px) 10px,      /* the artwork's bottom right corner */
            100% 0                      /* under the flap's bottom right corner */
        );
    }

    .inlay__spineText {
        writing-mode: horizontal-tb;
        transform: none;
        max-height: none;
        max-width: 100%;
    }

    /* Stacked, the column needs a height of its own, and a square one - as
       tall as it is wide, to match the cover under it - is room for sixteen
       songs on a big phone. So the songs decide it instead: the tabs, the
       paper's margin, and exactly --song-rows rows. Nine show without a
       scrollbar and the sheet stops there. */
    .inlay__list {
        height: calc(var(--sides-h) + 2 * var(--songs-pad)
                     + var(--song-rows) * var(--song-row));
    }
}

@media (prefers-reduced-motion: reduce) {
    .cassette--leaving,
    .cassette--arriving { animation: none; }
    .cassette__box { transition: none; }
    .cassette--leaving { display: none; }
    .box { transition: none; }
}
