/* ─────────────────────────────────────────────────────────────────────────
   library.css — /library, the public wheel library (P3 step 4)

   ⛔ EVERY COLOUR IS A THEME TOKEN, and each one was checked to EXIST before
   being used. `--theme-card-bg` and `--theme-text-muted` do NOT exist on this
   project and fail silently to their fallback, which is how a surface ends up
   white in every theme and 1:1 in high-contrast. The real names are
   `--theme-surface` / `--theme-muted`.

   ⛔⛔ A SURFACE DECLARES ITS OWN INK. Anything painted on `--theme-surface`
   takes `--theme-surface-text`, NEVER `--theme-text`: high-contrast sets BOTH
   `--theme-surface` and `--theme-text` to #ffffff — white text on a black page,
   black ink on white surfaces — so `--theme-text` on a card is invisible in
   exactly the theme chosen by the people who need contrast most.

   ⚠️ NO `*` INSIDE A COMMENT FOLLOWED BY A SLASH anywhere in this file: it ends
   the comment and silently kills every rule after it.
   ───────────────────────────────────────────────────────────────────────── */

.wsp-lib {
  max-width: 1120px;
}

/* ── head ─────────────────────────────────────────────────────────────── */
.wsp-lib-head {
  display: flex;
  flex-wrap: wrap;
  gap: 1rem 1.5rem;
  align-items: flex-end;
  justify-content: space-between;
  margin-bottom: 1.25rem;
}
.wsp-lib-h1 {
  font-size: clamp(1.6rem, 3.2vw, 2.2rem);
  font-weight: 800;
  margin: 0 0 .25rem;
  color: var(--theme-text, #212529);
}
.wsp-lib-sub {
  margin: 0;
  color: var(--theme-muted, #6c757d);
  font-size: .98rem;
}
/* ⛔ --theme-muted IS A *SURFACE* MUTED, and high-contrast is where that shows.
   It measures fine on a white card there (#1f2937 on #ffffff = 14.68:1) and
   1.43:1 on the PAGE, because high-contrast's page ground is #000000. This text
   sits on the page, not on a card.
   ⚠️ Scoped to the ONE theme that needs it: light (4.69) and dark (7.38) already
   pass, and a blanket override would move two working numbers to fix one broken
   one — which is how a contrast fix makes a different theme worse (SKILLS 86). */
/* ⛔⛔ `!important` AND HIGHER SPECIFICITY, BECAUSE THE SUBTITLE IS NOW AN h2.
   `themes.css` paints `body[data-theme] h2` with `!important`, so the moment
   this element became an h2 (for the shortened-H1 SEO rule) it took the ACCENT
   and the high-contrast override below silently stopped applying - a
   deliberate contrast fix disabled by a change somewhere else entirely.
   Measured on production before the fix: subtitle rgb(129,140,248) in dark and
   rgb(0,170,255) in high-contrast, i.e. shouting as loudly as the H1.
   Between two important declarations the more specific selector wins, so this
   needs both (SKILLS 121). */
body[data-theme] .wsp-lib-sub,
.wsp-lib-sub {
  color: var(--theme-muted, #6c757d) !important;
  font-weight: 400 !important;
}
body[data-theme="high-contrast"] .wsp-lib-sub {
  color: var(--theme-text, #fff) !important;
}

.wsp-lib-search {
  display: flex;
  gap: .5rem;
  flex: 0 1 320px;
}
.wsp-lib-search input {
  flex: 1;
  min-width: 0;
  padding: .55rem .8rem;
  border: 1px solid var(--theme-border, #dee2e6);
  border-radius: .55rem;
  background: var(--theme-surface, #fff);
  color: var(--theme-surface-text, #212529);
  font-size: .95rem;
}
.wsp-lib-search button {
  padding: .55rem 1rem;
  border: 0;
  border-radius: .55rem;
  background: var(--theme-accent, #6366f1);
  color: #fff;
  font-weight: 600;
  font-size: .95rem;
  cursor: pointer;
}

/* ── the pre-launch notice ────────────────────────────────────────────── */
.wsp-lib-preview {
  margin: 0 0 1.25rem;
  padding: .7rem .9rem;
  border: 1px solid var(--theme-border, #dee2e6);
  border-left: 3px solid var(--theme-accent, #6366f1);
  border-radius: 0 .5rem .5rem 0;
  background: var(--theme-surface, #fff);
  color: var(--theme-surface-text, #212529);
  font-size: .92rem;
}

/* ── category chips ───────────────────────────────────────────────────── */
.wsp-lib-chips {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: .45rem;
  margin-bottom: 1.4rem;
}

/* ── the two filter axes ─────────────────────────────────────────────────
   There are now two chip rows, Category and Type, and they compose. Without a
   name on each row a visitor meets twelve identical pills and has to infer the
   grouping from the words; the label is what makes two rows read as two
   questions rather than as one wrapped list.

   NOTE the ink: --theme-muted, which is the one colour every theme guarantees
   is legible on its own surface. The accent is tuned to the page ground and
   measured 3.73:1 on a surface in dark when the queue card tried it. */
.wsp-lib-axis {
  font-size: .74rem;
  font-weight: 700;
  letter-spacing: .08em;
  text-transform: uppercase;
  color: var(--theme-muted, #6b7280);
  margin-right: .15rem;
  /* it labels the row, it is not a control: never let it look tappable */
  user-select: none;
}

/* The two rows sit closer to each other than to the grid below, so they read as
   one filter block rather than as two unrelated strips. */
.wsp-lib-chips:not(.wsp-lib-chips--type) { margin-bottom: .55rem; }
.wsp-lib-chips--type { margin-bottom: 1.4rem; }
.wsp-lib-chip {
  display: inline-block;
  padding: .35rem .8rem;
  border: 1px solid var(--theme-border, #dee2e6);
  border-radius: 999px;
  background: var(--theme-surface, #fff);
  color: var(--theme-surface-text, #212529);
  font-size: .88rem;
  text-decoration: none;
  white-space: nowrap;
}
.wsp-lib-chip:hover { border-color: var(--theme-accent, #6366f1); }

/* ⛔⛔ THE ACTIVE CHIP IS AN INVERTED SURFACE, NOT THE ACCENT — and the accent
   version was measured BROKEN IN ALL THREE THEMES:

     light          4.47:1   white on #6366f1            marginal FAIL (AA 4.5)
     dark           1.50:1   accent-ink on accent        FAIL
     high-contrast  1.00:1   #00aaff on #00aaff          INVISIBLE

   Two separate causes. The chip is an <a>, so a theme rule colouring anchors
   beat the `color:#fff` declared here and the ink became the accent sitting on
   an accent background — an exact 1.00 is the signature of a colour compared
   with itself. And even with the ink forced, white-on-accent does not survive
   every theme: high-contrast's accent is a LIGHT blue (#00aaff) that needs BLACK
   ink, so no single ink works across the three.

   ⭐ --theme-surface-text ON --theme-surface is a pair the themes already define
   as legible, so inverting them is correct BY CONSTRUCTION in every theme
   present and future, instead of being a number to re-check (SKILLS 75). It
   still reads unmistakably as "selected" — a solid pill among outlined ones. */
/* ⛔⛔ AND THE INK NEEDS `!important`, WHICH IS NOT A STYLE CHOICE — IT IS THE
   ONLY THING THAT WINS. themes.css carries

       body[data-theme] a { color: var(--theme-accent-ink, …) !important }   (0,2,1)

   A CHIP IS AN <a>, so that rule lands on it DIRECTLY and a plain declaration
   loses however specific it is. The tell was precise and worth keeping: on the
   active chip my `background` applied and my `color` did not — one property
   winning while its neighbour loses is the signature of an `!important` you did
   not write (SKILLS 121).
   ⚠️ `.wsp-lib-name` needed no `!important` for the same reason in reverse: it
   is a <span>, so the theme rule only reaches it by INHERITANCE, and any direct
   declaration on the child beats that.
   ⇒ `!important` AND (0,3,1) > (0,2,1): between two important declarations the
   more specific selector wins, so this needs both. Colour only — a rule whose
   job is the palette must not also move size or spacing (SKILLS 1c). */
body .wsp-lib-chips .wsp-lib-chip.is-on {
  background: var(--theme-surface-text, #212529);
  border-color: var(--theme-surface-text, #212529);
  color: var(--theme-surface, #fff) !important;
  font-weight: 600;
}
/* The idle chip sits ON a surface, so it takes the surface's own ink — never
   --theme-text, which high-contrast sets to #ffffff for its BLACK page and
   would render white-on-white here (SKILLS 115). */
body .wsp-lib-chips .wsp-lib-chip {
  color: var(--theme-surface-text, #212529) !important;
}
.wsp-lib-chip .n {
  opacity: .7;
  font-size: .8em;
  margin-left: .15rem;
}

/* ── the grid ─────────────────────────────────────────────────────────── */
.wsp-lib-grid {
  list-style: none;
  padding: 0;
  margin: 0;
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(172px, 1fr));
  gap: .9rem;
}
.wsp-lib-card > a {
  display: flex;
  flex-direction: column;
  gap: .5rem;
  height: 100%;
  padding: .8rem;
  border: 1px solid var(--theme-border, #dee2e6);
  border-radius: .7rem;
  background: var(--theme-surface, #fff);
  color: var(--theme-surface-text, #212529);
  text-decoration: none;
}
.wsp-lib-card > a:hover {
  border-color: var(--theme-accent, #6366f1);
}
.wsp-lib-card > a:focus-visible {
  outline: 2px solid var(--theme-accent, #6366f1);
  outline-offset: 2px;
}

/* The art. A fixed aspect box with the wheel inscribed in it, so every card is
   the same size whatever the entry count — the alignment that matters is the
   relationship between cards, not any single number. */
/* ⛔⛔ SQUARE, NOT 3:2 — AND THIS IS WHY THE WHEEL "TOUCHED THE BORDERS".
   Owner, 2026-09-03. Measured on the real card at 1440px: the slot was
   180x120, the square capture rendered at 180x180, and `overflow: hidden`
   CLIPPED 60px - 30 off the top and 30 off the bottom of the disc.

   ⭐ `object-fit: contain` was INERT. With `display: grid` + `place-items:
   center` the image's `height: 100%` never resolved against the 120px box, so
   the image was never fitted - it overflowed and was cropped. A rule that does
   nothing looks exactly like a rule that works (SKILLS 120).

   ⛔ The fix is NOT to letterbox a square picture into a 3:2 box - that shrinks
   the wheel to 120px and it reads as a small disc floating in a wide frame.
   A SQUARE slot gives the disc the full width, whole, with room around it.
   Measured after: img 180x180 -> 180x120 clipped 60px  BECOMES  180x180 in a
   180x180 slot, 0px clipped.

   ⚠️ BLAST RADIUS, STATED: `.wsp-lib-disc` is shared with `.wsp-lib-wheel`, the
   generated conic-gradient disc every card without a capture uses. So EVERY
   library card's art box becomes square, not just the ones with a photo. That
   is deliberate - two aspect ratios in one grid is the ragged look this box
   exists to prevent - but it is a change to every card, not to one feature.

   ⚠️ TRANSPARENT, so the card surface shows through the corners of a round
   picture in whichever theme is active. The plate that used to be baked into
   the capture is gone (share-wheel.js captureStage). */
.wsp-lib-disc {
  /* flex, not grid: `place-items: center` centres the child without giving it
     a definite height, which is precisely what made `contain` inert above. */
  display: flex;
  align-items: center;
  justify-content: center;
  aspect-ratio: 1 / 1;
  border-radius: .45rem;
  background: transparent;
  overflow: hidden;
}
.wsp-lib-wheel {
  height: 78%;
  aspect-ratio: 1;
  border-radius: 50%;
  /* the inline `background` is the conic-gradient built from the row */
}

/* ⛔⛔ THE INK IS SET ON THE TEXT, NOT INHERITED FROM THE CARD'S LINK — and this
   was a REAL FAILURE caught by looking at the page while every check was green.
   The card is an <a>, so a theme rule that colours anchors wins over the colour
   declared on the anchor and the title inherits the ACCENT. Measured:

     light          15.43:1   ink 33,37,41    on 255,255,255   PASS
     dark            5.59:1   ink 129,140,248 on 30,30,30      PASS
     high-contrast   2.56:1   ink 0,170,255   on 255,255,255   *** FAIL, AA needs 4.5 ***

   Two of three themes passed, which is exactly why it survived review. And the
   one that failed is the theme chosen by the people who need contrast most —
   high-contrast sets --theme-surface #ffffff, --theme-surface-text #000000 and
   --theme-accent #00aaff, so the accent on a white card is the whole defect.

   ⇒ (0,3,1), so it beats any body[data-theme] a rule on specificity rather than
   on load order — a TIE would be decided by whichever sheet came last, and
   themes.css is linked twice on some page shapes. Beat it, do not tie it.
   ⇒ And it is declared on the CHILD: setting a colour on a parent and relying
   on inheritance is not equivalent, because any direct declaration on the child
   wins (SKILLS 121). */
body .wsp-lib-card .wsp-lib-name {
  color: var(--theme-surface-text, #212529);
}
body .wsp-lib-card .wsp-lib-meta,
body .wsp-lib-card .wsp-lib-by {
  color: var(--theme-muted, #6c757d);
}

/* The text column. On the desktop grid it simply stacks under the art; in app
   mode it stacks BESIDE it (see the app-mode block at the foot of this file). */
.wsp-lib-text {
  display: flex;
  flex-direction: column;
  gap: .15rem;
  min-width: 0;
}

.wsp-lib-name {
  font-weight: 600;
  font-size: .95rem;
  line-height: 1.3;
  /* Two lines, then ellipsis: a 120-character title must not make one card
     four times the height of its neighbours. */
  display: -webkit-box;
  -webkit-line-clamp: 2;
  -webkit-box-orient: vertical;
  overflow: hidden;
}
.wsp-lib-meta,
.wsp-lib-by {
  font-size: .8rem;
  color: var(--theme-muted, #6c757d);
}
.wsp-lib-by { margin-top: -.35rem; }

/* ── empty state + pager ──────────────────────────────────────────────── */
.wsp-lib-empty {
  text-align: center;
  padding: 3rem 1rem;
  color: var(--theme-muted, #6c757d);
}
/* ⛔ THE SAME PAGE-vs-SURFACE TRAP THE SUBTITLE ALREADY HAD DOCUMENTED, and
   this block never got the override. `--theme-muted` is tuned for a CARD; in
   high-contrast the page ground is #000000 and it measured 1.43:1 - on the
   sentence that tells a first visitor what to do. Scoped to the one theme that
   needs it: light 4.69 and dark 7.38 already pass (SKILLS 86). */
body[data-theme="high-contrast"] .wsp-lib-empty,
body[data-theme="high-contrast"] .wsp-lib-empty strong,
body[data-theme="high-contrast"] .wsp-lib-axis {
  color: var(--theme-text, #fff);
}

/* ⛔⛔ THE BUTTON IS AN <a>, AND THAT IS THE WHOLE BUG.
   `themes.css` carries `body[data-theme] a { color: ... !important }`, which
   beat `color:#fff` here and painted the label IN THE ACCENT - on an accent
   fill. Measured on production: dark 1.5:1, high-contrast 1:1. Literally
   invisible, on the only call to action an empty library has.

   ⭐ AND THE INK IS NOT THE LEVER - THE FILL IS. White on the light accent
   (#6366f1) is 4.47:1, which fails AA by 0.03: no ink fixes a fill that is
   too light, so the fill is darkened rather than the text nudged (the same
   ruling as the winner card Again pill - narrow the range, do not repaint the
   ink). Each theme states its own pair, and every number below was MEASURED
   after the change, not predicted. */
/* ── the captured stage ─────────────────────────────────────────────────
   It occupies exactly the box the generated disc does, so a card with a
   picture and a card without are the same size and the grid cannot reflow
   depending on which rows happen to have one.
   ⚠️ object-fit: contain, never cover. The client letterboxes the capture onto
   a white plate precisely so nothing is cropped; cover would undo that and cut
   the edge off a wheel. */
.wsp-lib-shot {
  width: 100%;
  height: 100%;
  /* ⭐ WITHOUT `min-height: 0` A FLEX ITEM WILL NOT SHRINK BELOW ITS CONTENT.
     The image's intrinsic height is 480px; the default `min-height: auto` lets
     it push past the slot and `object-fit` then has nothing to fit inside.
     This one declaration is what makes `contain` mean anything here. */
  min-height: 0;
  object-fit: contain;
  /* breathing room, so the disc is not welded to the frame */
  padding: 8%;
  box-sizing: border-box;
  border-radius: inherit;
  display: block;
}

.wsp-lib-btn {
  display: inline-block;
  margin-top: .5rem;
  padding: .5rem 1.1rem;
  border-radius: .55rem;
  background: #4f46e5;
  color: #ffffff !important;
  text-decoration: none;
  font-weight: 600;
}
/* ⛔⛔ ONE FILL AND ONE INK FOR ALL THREE THEMES, and the first attempt proves
   why. It set a dark ink on each theme's own accent - predicted "8:1 or better"
   in dark and MEASURED 4.02:1, because dark's accent is darker than I assumed.
   Three per-theme pairs is three numbers to re-check every time a theme is
   touched; a fixed pair is legible BY CONSTRUCTION and cannot drift with a token
   (SKILLS 75). Measured after this change: light 6.29 · dark 6.29 ·
   high-contrast 6.29, because contrast is ink-against-fill and the page behind
   it is irrelevant.
   ⚠️ It deliberately does NOT follow --theme-accent. A primary call to action
   that is unreadable in one theme is not a call to action. */
body[data-theme] .wsp-lib-btn {
  background: #4f46e5;
  color: #ffffff !important;
}
.wsp-lib-btn:hover, .wsp-lib-btn:focus-visible { filter: brightness(1.08); }
.wsp-lib-pager {
  display: flex;
  justify-content: center;
  margin-top: 1.75rem;
}

@media (max-width: 575.98px) {
  .wsp-lib-head { align-items: stretch; }
  .wsp-lib-search { flex: 1 1 100%; }
  .wsp-lib-grid { grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); }
}

/* ─────────────────────────────────────────────────────────────────────────
   APP MODE — ONE ROW PER WHEEL, NOT A GRID OF CARDS.

   ⛔ THIS IS WHAT D5 ACTUALLY APPROVED, and the first build shipped the
   desktop grid here instead. The design's own caption: *"/library in app mode
   — same bar, same 52px row rhythm, one row per wheel."* The app home is a
   LIST, and a library screen that arrives as a 3-across card grid reads as a
   different app. A card grid at 390px also gives each card ~140px, which wraps
   the titles to three lines.

   ⚠️ Keyed on `html.wsp-app` — the DISPLAY MODE, never the viewport width. A
   width query would restyle every phone browser too, which is the exact
   mistake app mode exists to avoid (SKILLS 113).
   ───────────────────────────────────────────────────────────────────────── */
html.wsp-app .wsp-lib-grid {
  grid-template-columns: 1fr;
  gap: 0;
  border: 1px solid var(--theme-border, #dee2e6);
  border-radius: .7rem;
  overflow: hidden;
  background: var(--theme-surface, #fff);
}

html.wsp-app .wsp-lib-card > a {
  flex-direction: row;
  align-items: center;
  gap: .7rem;
  border: 0;
  border-radius: 0;
  border-top: 1px solid var(--theme-border, #dee2e6);
  padding: .55rem .7rem;
}
/* The first row owns the container's top edge, so it must not draw its own. */
html.wsp-app .wsp-lib-card:first-child > a { border-top: 0; }

html.wsp-app .wsp-lib-disc {
  width: 44px;
  height: 32px;
  aspect-ratio: auto;
  flex: none;
  border-radius: .3rem;
}

/* ⚠️ The text column needs `min-width: 0` or a long title refuses to shrink
   below its content and pushes the row wider than the screen — a flex item's
   default `min-width: auto` is exactly the overflow this layout would
   otherwise reintroduce (SKILLS 58). */
html.wsp-app .wsp-lib-text { min-width: 0; }
html.wsp-app .wsp-lib-name {
  -webkit-line-clamp: 1;
  font-size: .92rem;
}
html.wsp-app .wsp-lib-meta,
html.wsp-app .wsp-lib-by {
  font-size: .74rem;
}
html.wsp-app .wsp-lib-by { margin-top: 0; }
