/* ============================================================================
   hub-tiles.css — THE SUB-HUB TILE.
   public/src/hubui/hub-tiles.css   (served as /src/hubui/hub-tiles.css)

   THE CLAUSE THIS FILE ANSWERS — docs/hub-ui-bar.md §3 "Tile grid", every
   bullet of it:
     · four across, then the remainder centred below, gap ~22px, ~300×330
     · a notched 1px gold hairline frame with a soft outer shadow
     · an ART PANEL filling the top ~55%, its own inset gold hairline, painted
       art FILLING the panel — never an icon floating in an empty well
     · gold Cinzel caps ~26px title, one line of italic gold-grey subtitle
     · outlined badge pills — gold for neutral, violet for special
     · one promoted tile per hub: violet frame + violet outer glow
     · hover / active / focus-visible / disabled, ~160ms on the token easing

   WHAT THIS FILE OWNS
     `.spellbook-hub.is-sub .hub-portal-grid` and everything inside a tile.
     Nothing else. The background plate, the top bar, the breadcrumb, the title
     block and the footer belong to hub-keep.css / hub-chrome.css.

   TOKENS
     Consumes the `--keep-*` dictionary declared by hub-keep.css and declares
     none of its own on :root. The handful of `--tile-*` customs below are
     LOCAL, scoped to the grid, and exist so a hub can retune size in one place.

   ── FOUR THINGS THAT LOOK LIKE MISTAKES AND ARE NOT ──────────────────────
   1. SCOPE IS `.spellbook-hub.is-sub.hub-v2 …`, always three classes deep.
      index.html still carries the pre-remodel tile rules (~line 1372 and the
      `portal-locked` pair at ~25734). `.hub-v2` is the opt-in switch AND the
      extra specificity class that beats them. Dropping it to "tidy up" hands
      the tile straight back to the legacy sheet.
   2. THE TILE IS THREE LAYERS, AND THE SPLIT IS LOAD-BEARING.
      `.hub-portal` itself is UNCLIPPED and paints nothing: it exists to carry
      `transform` and the `filter: drop-shadow()` outer shadow. `::before` is
      the notched gold hairline FRAME; `::after` is the stone FACE, 1px inside
      it.  ⚠ DO NOT PUT `clip-path` BACK ON `.hub-portal`.
      Per CSS Filter Effects the filter is applied BEFORE `clip-path`, so an
      element that carries both has its own drop-shadow sliced off at its own
      silhouette. Round 1 did exactly that and shipped tiles with NO outer
      shadow at all and a promoted "glow" that was a 1px violet line — measured
      in isolation: same-element filter+clip leaves lum 42 / sat 0.00 just
      outside the edge (i.e. bare background), the split leaves lum 61 / sat
      0.57. `box-shadow` is not the alternative: it would be clipped by the
      frame layer's own clip-path. The split is.
      ⚠ Keep the NUMBER of drop-shadow() functions identical between a state
      and the state it transitions to, or the filter snaps instead of easing.
      Gold tiles use one; promoted (violet) tiles use three in every state.
   3. THE FRAME IS A BACKGROUND, NOT A BORDER. `::before`'s background IS the
      1px gold hairline; `::after` paints the stone face 1px inside it.
      A real `border` cannot do this — clip-path slices the corners off and the
      four diagonal bevel edges come out bare, which is the "div with a border"
      look the remodel exists to kill.
   4. NO `animation` IS DECLARED HERE, AND NO `animation:none !important`.
      The tile-band entrance belongs to hub-transitions.css, including its
      reduced-motion fallback. The previous version of this file killed tile
      animation with `!important`, which silently ate that sheet's `reduce`
      fade (its §"AND A SECOND RULE, ON THE GRID" comment documents the
      collision). Transitions are ours; animations are theirs.
   ========================================================================== */


/* ════════════════════════════════════════════════════════════════════════════
   1 · THE GRID  (§3 "Four across, then three centred below. Gap ~22px.")
   Plain flex wrap, not CSS grid: wrapping flex centres a SHORT LAST ROW for
   free, which is the comp's 4-then-3. `grid-template-columns` would leave the
   remainder left-aligned and need a `nth-last-child` hack per tile count, and
   the hubs run from 3 tiles (The Camp) to 13 (Ruin Exchange).

   WIDTH IS PINNED TO EXACTLY FOUR COLUMNS — 4×300 + 3×22 = 1266px — so the
   wrap point is a decision, not an accident of the viewport. Below ~1330px of
   usable width it falls to three, then two, then one.

   ⚠ THE RUIN EXCHANGE RENDERS 13 TILES AGAINST THE COMP'S 7. Four across puts
   it in four rows (1386px of band) and the page scrolls. That is the deliberate
   trade: the alternative — shrinking the tile until 13 fit one screen — breaks
   the ~300×330 the bar specifies on every OTHER hub too, to fix one. So the
   band is allowed to run past the fold and `.spellbook-hub` (overflow-y:auto)
   scrolls it. A clipped bottom row would not be acceptable; a scrolled one is.
   ════════════════════════════════════════════════════════════════════════════ */
.spellbook-hub.is-sub.hub-v2 .hub-portal-grid {
  /* Local size dictionary. A hub can retune all three by redeclaring them on
     `.spellbook-hub.is-sub[data-hub="…"] .hub-portal-grid`. */
  --tile-w: 300px;
  --tile-h: 330px;
  --tile-art-h: 178px;
  --tile-gap: 22px;
  --tile-notch: 10px;        /* the outer frame's corner cut */
  --tile-notch-in: 9px;      /* the stone face, 1px inside it */
  --tile-notch-art: 6px;     /* the art panel */

  position: relative;
  z-index: 3;
  flex: 0 0 auto;
  display: flex;
  flex-wrap: wrap;
  justify-content: center;
  align-content: flex-start;
  align-items: stretch;
  gap: var(--tile-gap);
  width: min(calc(var(--tile-w) * 4 + var(--tile-gap) * 3), 94vw);
  max-width: none;
  margin: 26px auto 0;
  padding: 0;
  list-style: none;
}


/* ════════════════════════════════════════════════════════════════════════════
   2 · THE TILE — the notched stone plaque
   ════════════════════════════════════════════════════════════════════════════ */
.spellbook-hub.is-sub.hub-v2 .hub-portal {
  position: relative;
  isolation: isolate;
  box-sizing: border-box;
  width: var(--tile-w, 300px);
  height: var(--tile-h, 330px);
  min-height: var(--tile-h, 330px);
  flex: 0 0 auto;

  display: flex;
  flex-direction: column;
  align-items: stretch;
  text-align: center;

  /* The frame is the padding: `::before`'s 1px of gold shows all the way round,
     on every one of the octagon's eight edges. */
  padding: 1px;
  margin: 0;
  border: 0;
  border-radius: 0;
  cursor: pointer;
  overflow: visible;
  -webkit-tap-highlight-color: transparent;

  /* NOTHING IS PAINTED HERE AND NOTHING IS CLIPPED HERE — see header note 2.
     This element is only the shadow host. Its `filter` would be sliced off at
     its own silhouette the instant it also carried a `clip-path`.
     ⚠ `box-shadow: none` IS LOAD-BEARING, here and on :hover. index.html's
     legacy `.hub-portal` carries `0 8px 26px + inset 0 0 44px` and its hover
     adds `0 0 32px var(--portal-accent)` — a per-portal COLOURED glow, the
     signature of the purple design being replaced. While this element clipped
     itself those were invisible; the moment the clip moved to `::before` they
     would have come back, and the accent glow would have fired on every
     hover. */
  background: none;
  box-shadow: none;

  transform: translateY(0);
  filter: drop-shadow(0 12px 22px rgba(0, 0, 0, 0.66));

  transition:
    transform var(--keep-fast, 0.16s) var(--keep-ease, ease),
    filter    var(--keep-fast, 0.16s) var(--keep-ease, ease);
}

/* THE FRAME — the 1px GOLD HAIRLINE, notched. A vertical ramp so the edge
   catches light at the top and sinks into shadow at the foot, the way a struck
   metal edge does. */
.spellbook-hub.is-sub.hub-v2 .hub-portal::before {
  content: '';
  position: absolute;
  inset: 0;
  z-index: 0;
  border: 0;
  width: auto;
  height: auto;
  display: block;
  background: linear-gradient(
    180deg,
    rgba(203, 170, 106, 0.78) 0%,
    rgba(138, 111, 60, 0.46) 55%,
    rgba(110, 86, 44, 0.55) 100%
  );
  clip-path: polygon(
    var(--tile-notch) 0, calc(100% - var(--tile-notch)) 0,
    100% var(--tile-notch), 100% calc(100% - var(--tile-notch)),
    calc(100% - var(--tile-notch)) 100%, var(--tile-notch) 100%,
    0 calc(100% - var(--tile-notch)), 0 var(--tile-notch)
  );
  transition: background var(--keep-fast, 0.16s) var(--keep-ease, ease);
  pointer-events: none;
}

/* THE STONE FACE. Warm near-black vertical gradient, 1px inside the hairline,
   carrying the §1 emboss + liner insets — and, in §7, the focus ring and the
   press. (The legacy sheet used `::after` for a pair of corner brackets that
   belong to the design being replaced; they are retired rather than restyled —
   §1's ornaments are "sparse and deliberate", and a bracket on all thirteen
   Exchange tiles is a border of them.) */
.spellbook-hub.is-sub.hub-v2 .hub-portal::after {
  content: '';
  position: absolute;
  inset: 1px;
  z-index: 0;
  border: 0;
  width: auto;
  height: auto;
  display: block;
  clip-path: polygon(
    var(--tile-notch-in) 0, calc(100% - var(--tile-notch-in)) 0,
    100% var(--tile-notch-in), 100% calc(100% - var(--tile-notch-in)),
    calc(100% - var(--tile-notch-in)) 100%, var(--tile-notch-in) 100%,
    0 calc(100% - var(--tile-notch-in)), 0 var(--tile-notch-in)
  );
  background: linear-gradient(
    180deg,
    var(--keep-stone-hi, #1a150e) 0%,
    var(--keep-ground-1, #12100c) 46%,
    var(--keep-ground-0, #0b0906) 100%
  );
  box-shadow: var(--keep-emboss), var(--keep-liner);
  transition: box-shadow var(--keep-fast, 0.16s) var(--keep-ease, ease);
  pointer-events: none;
}

/* Every real child sits above the face. */
.spellbook-hub.is-sub.hub-v2 .hub-portal > * {
  position: relative;
  z-index: 1;
}


/* ════════════════════════════════════════════════════════════════════════════
   3 · THE ART PANEL  (§3: "filling the top ~55% … Art fills the panel — it is
   not an icon floating in an empty well.")
   178 / 330 = 54%.
   ════════════════════════════════════════════════════════════════════════════ */
.spellbook-hub.is-sub.hub-v2 .hub-portal-art {
  position: relative;
  display: block;
  flex: 0 0 auto;
  width: auto;
  height: var(--tile-art-h, 178px);
  margin: 7px 7px 0;
  padding: 0;
  overflow: hidden;
  /* Contains the colour-grade blend below — without it the blend would reach
     past the panel and take the painted page background with it. */
  isolation: isolate;
  /* THE REST STATE OF THE PANEL — what is on screen before the crop decodes,
     and what a tile with no art at all shows through §3's fallback rule below.
     It is a lit stone recess (warm ochre bounce off the foot, cool-black crown)
     rather than flat black, because a 1px gold hairline around a hole is the
     "icon floating in an empty well" the clause forbids, reached from the other
     direction. Round 1 shipped that hole for the first seconds of every visit.
     Baking the art down to <80KB webp crops (assets/hubtiles/) is the other
     half of the fix; this is the half that covers the frame the crop misses. */
  background:
    linear-gradient(180deg, rgba(246, 220, 149, 0.05) 0%, rgba(0, 0, 0, 0) 26%),
    radial-gradient(76% 86% at 50% 112%, rgba(184, 134, 58, 0.20) 0%, rgba(112, 80, 34, 0.09) 42%, rgba(0, 0, 0, 0) 74%),
    radial-gradient(122% 104% at 50% 30%, #1c1610 0%, #110d09 58%, #0a0806 100%);
  clip-path: polygon(
    var(--tile-notch-art) 0, calc(100% - var(--tile-notch-art)) 0,
    100% var(--tile-notch-art), 100% calc(100% - var(--tile-notch-art)),
    calc(100% - var(--tile-notch-art)) 100%, var(--tile-notch-art) 100%,
    0 calc(100% - var(--tile-notch-art)), 0 var(--tile-notch-art)
  );
  border: 0;
  border-radius: 0;
  box-shadow: none;
}

/* THE PAINTING. `cover`, biased slightly above centre: most of the repo's
   panel art is a 3:2 or square scene whose subject sits above the midline, and
   a dead-centre crop of a 512×512 takes its feet and its sky in equal measure. */
.spellbook-hub.is-sub.hub-v2 .hub-portal-photo {
  position: absolute;
  inset: 0;
  display: block;
  width: 100%;
  height: 100%;
  object-fit: cover;
  object-position: 50% 42%;
  border: 0;
  border-radius: 0;
  /* THE GRADE. Four functions, and every other photo filter in this file
     restates all four in the same order — a `filter` list only interpolates
     function-by-function, so a state that drops one snaps instead of easing.
       brightness  §3's "darkened so the title below stays dominant". Measured
                   over the exact 284×178 crop a tile shows, this lands the
                   default bucket at ~19–23 mean luminance, which is where the
                   comp's own panels sit (Quick Battle 19.0, Season Pass 21.3,
                   Colosseum 19.4). Round 1 ran 0.80 and measured 23–30: the
                   painting was competing with its own gold title. The two
                   bucket overrides below restate it; index.html carries the
                   measurement each image was sorted by.
       saturate    the library is not colour-managed for this palette. Pack
                   Opening is an electric-blue vault, the Lab is cyan screens,
                   World Data is a magenta galaxy — at full chroma they read as
                   photographs pasted into a gold/near-black system.
       sepia       and this is what pulls them back INTO it. 20% toward sepia
                   is a warm grade, not a tint: it bends the cool half of the
                   library toward the hub's amber without flattening the warm
                   half into monochrome. Cheaper and truer than a wash layer,
                   which would lift the blacks instead of turning the hues.
       contrast    buys back the local contrast the two above cost. */
  filter: brightness(0.78) saturate(0.78) sepia(0.20) contrast(1.06);
  transform: scale(1.02);
  transition:
    filter    var(--keep-fast, 0.16s) var(--keep-ease, ease),
    transform var(--keep-fast, 0.16s) var(--keep-ease, ease);
}

/* ── THE WARM GRADE ────────────────────────────────────────────────────────
   `mix-blend-mode: color` takes its HUE AND SATURATION from this layer and its
   LUMINANCE from the painting underneath, so at 34% it pulls every panel a
   third of the way to one warm key WITHOUT touching a single value. That is
   the difference between a graded frame and a tinted one, and it is the piece
   `filter: sepia()` alone cannot do: sepia barely moves a near-white specular,
   which is exactly what The Lab's cyan monitors and Pack Opening's blue
   lightning are made of. Round 1 had no grade at all and those two plates —
   plus World Data's magenta galaxy — read as cool photographs pasted into a
   gold/near-black system where every comp panel is warm and subdued.
   Measured by eye against 0 / .22 / .34 / .46: at .46 the paintings go
   monochrome and stop being paintings; .34 warms the cool half of the library
   and leaves the warm half looking untouched. */
.spellbook-hub.is-sub.hub-v2 .hub-portal-art::before {
  content: '';
  position: absolute;
  inset: 0;
  z-index: 1;
  pointer-events: none;
  background: rgb(168, 104, 44);
  mix-blend-mode: color;
  opacity: 0.34;
}

/* THE INSET HAIRLINE + THE BOTTOM-WEIGHTED SCRIM.
   The scrim is the piece that makes the title dominant: the art is at full
   exposure through its top two thirds and falls into the plaque at its foot,
   so the panel reads as painted INTO the stone rather than pasted onto it. */
.spellbook-hub.is-sub.hub-v2 .hub-portal-art::after {
  content: '';
  position: absolute;
  inset: 0;
  z-index: 2;
  pointer-events: none;
  background:
    linear-gradient(180deg,
      rgba(11, 9, 6, 0.22) 0%,
      rgba(11, 9, 6, 0.00) 30%,
      rgba(11, 9, 6, 0.20) 70%,
      rgba(11, 9, 6, 0.70) 100%),
    radial-gradient(94% 76% at 50% 40%,
      rgba(0, 0, 0, 0) 44%,
      rgba(0, 0, 0, 0.36) 100%);
  box-shadow:
    inset 0 0 0 1px var(--keep-gold-line, rgba(138, 111, 60, 0.55)),
    inset 0 1px 0 rgba(246, 220, 149, 0.08);
}

/* ── ART TONE: THREE EXPOSURE BUCKETS, CHOSEN BY MEASUREMENT ──────────────
   The comp carries two modifiers on its art panel (`is-dim` / `is-bright`) and
   they are not decoration: the repo's painted art runs from a mean luminance of
   15 to 84 over the exact 284×178 crop a tile shows — a 5.6× spread. One flat
   `brightness()` across that range leaves half the band as black rectangles and
   the other half shouting over its own title.

   So each mapped image was MEASURED (drawn into a canvas at the tile's crop,
   mean of 0.2126R+0.7152G+0.0722B) and index.html tags the tile with the bucket
   its art falls in. The three multipliers below pull every panel into roughly
   the same 20–44 output band, which is where a painting reads as painted stone
   and the gold title above it still wins.

   An admin's override art is unmeasured and therefore lands in the default
   bucket — deliberately, since guessing at an arbitrary URL would be worse.
   ─────────────────────────────────────────────────────────────────────────── */
.spellbook-hub.is-sub.hub-v2 .hub-portal.is-art-dim .hub-portal-photo {
  filter: brightness(1.28) saturate(0.86) sepia(0.16) contrast(1.04);
}
.spellbook-hub.is-sub.hub-v2 .hub-portal.is-art-bright .hub-portal-photo {
  filter: brightness(0.48) saturate(0.70) sepia(0.24) contrast(1.06);
}
/* ⚠ EVERY HOVER NUMBER BELOW IS DERIVED FROM THE REST VALUE IN *THIS* FILE —
   rest × ~1.11 — and NOT copied out of the comp. Round 1 lifted the comp's
   hover brightness (.79) onto its own raised rest (.80), which made hover 1%
   DARKER than rest: the default bucket, which is also where every admin
   override lands, sat dead under the cursor while its neighbours brightened.
   If you retune a rest value, rescale its hover by the same ratio here.
   The lift also has to be restated per bucket: the generic hover rule is one
   class less specific than these, so a toned panel would otherwise not move. */
.spellbook-hub.is-sub.hub-v2 .hub-portal.is-art-dim:hover .hub-portal-photo {
  filter: brightness(1.42) saturate(0.92) sepia(0.16) contrast(1.04);
}
.spellbook-hub.is-sub.hub-v2 .hub-portal.is-art-bright:hover .hub-portal-photo {
  filter: brightness(0.53) saturate(0.76) sepia(0.24) contrast(1.06);
}

/* THE GLYPH IS THE FALLBACK, NEVER THE DESIGN.
   Every sub-hub tile ships a default painting (index.html's HUB_TILE_ART), and
   an admin override beats that. The icon only surfaces when a tile has no art
   at all or its image 404s — `.has-art` is dropped by the <img>'s own onerror,
   which is why this is keyed off the ABSENCE of the class rather than off the
   img. When it does surface it gets a deliberate treatment: a warm forge-lit
   well with a vignette and a gold under-glow, not a glyph on flat black. */
.spellbook-hub.is-sub.hub-v2 .hub-portal.has-art .hub-portal-icon {
  display: none;
}
.spellbook-hub.is-sub.hub-v2 .hub-portal:not(.has-art) .hub-portal-art {
  background:
    radial-gradient(78% 92% at 50% 108%, rgba(190, 142, 58, 0.30) 0%, rgba(120, 88, 36, 0.10) 44%, rgba(0, 0, 0, 0) 72%),
    radial-gradient(120% 100% at 50% 30%, #1d1710 0%, #100c08 58%, #0a0806 100%);
}
.spellbook-hub.is-sub.hub-v2 .hub-portal-icon {
  position: absolute;
  inset: 0;
  /* Above the grade (1) and the scrim (2): the glyph is the fallback ART, and
     dimming it with the layers meant for a photograph makes it look forgotten
     rather than deliberate. */
  z-index: 3;
  display: flex;
  align-items: center;
  justify-content: center;
  margin: 0;
  padding: 0;
  width: auto;
  height: auto;
  font-size: 58px;
  line-height: 1;
  color: var(--keep-gold-hi, #e8c877);
  background: none;
  border: 0;
  border-radius: 0;
  box-shadow: none;
  filter: drop-shadow(0 3px 6px rgba(0, 0, 0, 0.85));
  transition: filter var(--keep-fast, 0.16s) var(--keep-ease, ease);
}
/* The legacy rune ring around the glyph is part of the old design. */
.spellbook-hub.is-sub.hub-v2 .hub-portal-icon::after,
.spellbook-hub.is-sub.hub-v2 .hub-portal-icon::before {
  content: none;
  display: none;
}
/* Several portals ship their glyph as an <img> with inline width/height and a
   `transform: scale(…)`. Those inline styles win over any width we could set,
   so we do not fight them — we only cap the result so a scaled glyph cannot
   spill out of the panel. */
.spellbook-hub.is-sub.hub-v2 .hub-portal-icon img {
  max-width: 62%;
  max-height: 62%;
  object-fit: contain;
}


/* ════════════════════════════════════════════════════════════════════════════
   4 · TITLE + SUBTITLE  (§3: gold Cinzel caps ~26px; one line of italic serif
   subtitle in muted gold-grey.)
   ════════════════════════════════════════════════════════════════════════════ */
.spellbook-hub.is-sub.hub-v2 .hub-portal-name {
  /* ── THE TITLE BOX IS A FIXED BAND, AND THE STEPS ONLY CHANGE THE TYPE ──
     `height` and `line-height` are declared HERE and never restated by the
     size steps below, so a 26px title, a 23px title and a 19px title in the
     same row share one baseline and one block of space. Round 1 let the box
     shrink with the font: Forge row 1 read THE LAB / DECK BUILDER / PACK
     OPENING / EMERGENCY BROADCAST at three different sizes on three different
     baselines, which is what makes a menu read as auto-generated instead of
     art-directed. A `line-height` in `px` (not a ratio) is the whole trick. */
  flex: 0 0 auto;
  box-sizing: border-box;
  height: 44px;
  margin: 0;
  padding: 10px 12px 0;
  font-family: var(--keep-cinzel, 'Cinzel', Georgia, serif);
  font-size: 26px;
  font-weight: 700;
  font-style: normal;
  line-height: 34px;
  letter-spacing: 0.07em;
  text-indent: 0.07em;          /* pay back the trailing letterspace */
  text-transform: uppercase;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
  color: var(--keep-gold-hi, #e8c877);
  /* Stamped metal. This one is a FLAT colour, not a background-clip gradient,
     so `text-shadow` is safe and correct here — the drop-shadow-only rule in
     hub-keep.css exists because Chromium paints text-shadow OVER a
     background-clip:text fill. There is no fill to hollow out at 26px. */
  text-shadow:
    0 2px 0 rgba(0, 0, 0, 0.85),
    0 0 14px rgba(232, 200, 119, 0.16);
  transition: color var(--keep-fast, 0.16s) var(--keep-ease, ease);
}
/* Two steps down, chosen by name length in index.html — but ONLY as the
   first-paint guess. `_hubFitPortalTitles()` re-measures every title against
   its real box the moment the hub mounts (and again when Cinzel finishes
   loading) and sets the largest size that fits, 1px at a time; character count
   is a proxy for width, not width. These two exist so a long title does not
   paint one frame as `BOOK OF KNOWLE…` before that runs.
   Note that neither step touches height or line-height — see the band note
   above; that is what keeps a stepped title on its row's baseline. */
.spellbook-hub.is-sub.hub-v2 .hub-portal-name.is-long  { font-size: 23px; letter-spacing: 0.055em; text-indent: 0.055em; }
.spellbook-hub.is-sub.hub-v2 .hub-portal-name.is-xlong { font-size: 19px; letter-spacing: 0.045em; text-indent: 0.045em; }

.spellbook-hub.is-sub.hub-v2 .hub-portal-sub {
  /* ONE RESERVED LINE — §3, and a fixed 18px of it whether the tile has a
     subtitle or not, so the pill rows line up across a row of tiles. Round 1
     allowed two, and four tiles per hub took the second one: ragged,
     bottom-heavy plaques standing next to single-line neighbours. The clamp is
     the backstop, not the plan — the long subtitles in index.html were cut to
     fit rather than left to be truncated. */
  flex: 0 0 auto;
  box-sizing: border-box;
  /* ⚠ `min-height` AND `max-height`, not just `height`. index.html's legacy
     `.hub-portal-sub` (~line 1440) declares `min-height: 2.4em` — two lines —
     and a `height` from a more specific rule does NOT beat a `min-height` from
     a less specific one, they are different properties. Measured: the band was
     coming out 32.4px tall with one line of text in it. */
  height: 18px;
  min-height: 18px;
  max-height: 18px;
  margin: 5px 0 0;
  padding: 0 13px;
  font-family: var(--keep-serif-it, Georgia, serif);
  font-style: italic;
  font-weight: 400;
  font-size: 13.5px;
  line-height: 18px;
  letter-spacing: 0.015em;
  text-transform: none;
  color: var(--keep-gold-grey, #9a8d6c);
  display: -webkit-box;
  -webkit-box-orient: vertical;
  -webkit-line-clamp: 1;
  line-clamp: 1;
  overflow: hidden;
  transition: color var(--keep-fast, 0.16s) var(--keep-ease, ease);
}


/* ════════════════════════════════════════════════════════════════════════════
   5 · BADGE PILLS  (§3: "outlined rounded-rects. Gold outline for neutral,
   purple outline for special.")

   ⚠ THE VIOLET STATE IS KEYED OFF `.alert`, WHICH ALREADY EXISTS.
   index.html sets `.alert` from each portal's `alertBadge`, and
   `_refreshOnlinePresenceUi()` TOGGLES that class on the live Play Online tile
   every few seconds. Inventing a new `.is-special` class would have left the
   live tile permanently gold. The mapping is also exactly right: the comp's two
   violet pills are `Completing` and `Resets at Midnight`, which are precisely
   the two the data marks as alerts.

   The pill is also the only rounded thing on the plaque, and deliberately so —
   §3 asks for "rounded-rects" here while §4 bans rounded rectangles for panels.
   ════════════════════════════════════════════════════════════════════════════ */
.spellbook-hub.is-sub.hub-v2 .hub-portal-badge {
  /* `margin-top:auto` pins the pill row to the foot of the plaque, so tiles with
     a one-line and a two-line subtitle still line their pills up across a row.
     A tile with no badge renders a `visibility:hidden` placeholder (index.html),
     which holds exactly this space — that is why it exists, do not delete it. */
  flex: 0 0 auto;
  align-self: center;
  margin: auto 0 0;
  padding: 0 11px;
  height: auto;
  min-height: 22px;
  max-width: calc(100% - 20px);
  display: inline-flex;
  align-items: center;
  justify-content: center;
  gap: 5px;

  border: 1px solid var(--keep-gold-line, rgba(138, 111, 60, 0.55));
  border-radius: 11px;
  background: rgba(138, 111, 60, 0.08);
  box-shadow: none;

  font-family: var(--keep-cinzel, 'Cinzel', Georgia, serif);
  font-size: 11px;
  font-weight: 600;
  font-style: normal;
  letter-spacing: 0.09em;
  line-height: 1.35;
  text-transform: none;
  /* WRAPS, DOES NOT CLIP. The comp's pills are all short ("Online · 4"), so
     `white-space:nowrap` + `text-overflow:ellipsis` is the obvious rule — and
     it is wrong here: ellipsis does not apply to the anonymous flex item a
     text node becomes inside an inline-flex, so a long badge came out sliced
     mid-word at BOTH ends with no ellipsis to explain it. Every badge the six
     hubs actually ship is now a short chip (the Ruin Exchange's gate badges
     were whole sentences until index.html cut them to progress counters), but
     one of them is data-driven and could grow, so the pill wraps and grows
     rather than slicing, capped at three lines so a freak badge can never push
     the plaque's contents past its foot. A one-line badge measures exactly
     22px tall — identical to the comp. */
  white-space: normal;
  overflow-wrap: anywhere;
  overflow: hidden;
  max-height: calc(1.35em * 3);
  color: var(--keep-gold-body, #c9ab72);
  text-shadow: 0 1px 0 rgba(0, 0, 0, 0.8);
  animation: none;
  transition:
    border-color var(--keep-fast, 0.16s) var(--keep-ease, ease),
    color        var(--keep-fast, 0.16s) var(--keep-ease, ease);
}
/* The pill row sits inside the plaque, clear of the bottom notch. */
.spellbook-hub.is-sub.hub-v2 .hub-portal-badge:last-child { margin-bottom: 14px; }

/* SPECIAL — violet outline. §1: purple is an accent, never a fill. */
.spellbook-hub.is-sub.hub-v2 .hub-portal-badge.alert {
  border-color: rgba(154, 122, 208, 0.72);
  color: #c3a9ea;
  background: rgba(107, 79, 160, 0.14);
  box-shadow: 0 0 10px rgba(107, 79, 160, 0.22);
  text-shadow: 0 1px 0 rgba(0, 0, 0, 0.85);
}

/* LOCKED — the pill on a gated tile. Reads as cold stone, not as an alert. */
.spellbook-hub.is-sub.hub-v2 .hub-portal-badge.lock {
  font-size: 10px;
  letter-spacing: 0.06em;
  padding: 3px 10px;
  border-color: rgba(138, 111, 60, 0.34);
  background: rgba(0, 0, 0, 0.34);
  color: rgba(201, 171, 114, 0.55);
  box-shadow: none;
}


/* ════════════════════════════════════════════════════════════════════════════
   6 · THE PROMOTED TILE  (§3: "violet frame and a violet outer glow instead of
   gold. Exactly one per hub at most.")
   index.html enforces the "at most one" — the class comes from a per-hub map,
   and a locked tile never gets it.
   ════════════════════════════════════════════════════════════════════════════ */
/* THE BLOOM IS TWO PASSES, NOT ONE. The comp's promoted halo visibly bleeds
   onto its neighbours; a single tight pass reads as a violet outline instead.
   So: a near pass that hugs the frame, and a WIDE low-alpha pass that carries
   past the 22px gutter and lands on the tiles either side. THREE drop-shadows
   here and in every promoted state below — see header note 2 on keeping the
   count stable so the filter eases instead of snapping. */
.spellbook-hub.is-sub.hub-v2 .hub-portal.is-promoted {
  filter:
    drop-shadow(0 0 18px rgba(148, 114, 206, 0.42))
    drop-shadow(0 0 46px rgba(126, 90, 204, 0.34))
    drop-shadow(0 12px 22px rgba(0, 0, 0, 0.62));
}
.spellbook-hub.is-sub.hub-v2 .hub-portal.is-promoted::before {
  background: linear-gradient(
    180deg,
    rgba(154, 122, 208, 0.92) 0%,
    rgba(107, 79, 160, 0.62) 55%,
    rgba(78, 56, 120, 0.66) 100%
  );
}
.spellbook-hub.is-sub.hub-v2 .hub-portal.is-promoted::after {
  background: linear-gradient(180deg, #181125 0%, #110e15 48%, var(--keep-ground-0, #0b0906) 100%);
}
.spellbook-hub.is-sub.hub-v2 .hub-portal.is-promoted .hub-portal-art::after {
  box-shadow:
    inset 0 0 0 1px rgba(154, 122, 208, 0.52),
    inset 0 1px 0 rgba(220, 205, 246, 0.08);
}


/* ════════════════════════════════════════════════════════════════════════════
   7 · STATES  (§3 + §4 "Every state earns its keep")
   ~160ms on the token easing. Never linear.
   ════════════════════════════════════════════════════════════════════════════ */

/* ── HOVER — the frame brightens to highlight gold, the art lifts its exposure
      a few percent, the tile rises ~3px into a deeper shadow. ── */
.spellbook-hub.is-sub.hub-v2 .hub-portal:hover {
  transform: translateY(-3px);
  box-shadow: none;                 /* see §2 — kills the legacy accent glow */
  filter: drop-shadow(0 18px 30px rgba(0, 0, 0, 0.75));
}
.spellbook-hub.is-sub.hub-v2 .hub-portal:hover::before {
  background: linear-gradient(180deg,
    var(--keep-gold-hi-2, #f2dda2) 0%,
    var(--keep-gold-hi, #e8c877) 55%,
    #b08b45 100%);
}
.spellbook-hub.is-sub.hub-v2 .hub-portal:hover .hub-portal-photo {
  /* 0.78 × 1.11 — see the note on the bucket hovers in §3. */
  filter: brightness(0.87) saturate(0.84) sepia(0.20) contrast(1.06);
  transform: scale(1.045);
}
.spellbook-hub.is-sub.hub-v2 .hub-portal:hover .hub-portal-name {
  color: var(--keep-gold-hi-2, #f2dda2);
}
.spellbook-hub.is-sub.hub-v2 .hub-portal:hover .hub-portal-icon {
  filter: drop-shadow(0 3px 6px rgba(0, 0, 0, 0.85)) brightness(1.12);
}
.spellbook-hub.is-sub.hub-v2 .hub-portal.is-promoted:hover::before {
  background: linear-gradient(180deg, #c3a9ea 0%, var(--keep-violet-hi, #9a7ad0) 55%, var(--keep-violet, #6b4fa0) 100%);
}
.spellbook-hub.is-sub.hub-v2 .hub-portal.is-promoted:hover {
  filter:
    drop-shadow(0 0 22px rgba(166, 132, 224, 0.54))
    drop-shadow(0 0 58px rgba(140, 102, 220, 0.42))
    drop-shadow(0 18px 30px rgba(0, 0, 0, 0.70));
}

/* ── ACTIVE / PRESS — drops back to 0 and the inner shadow deepens. A physical
      press, not a scale-down. ── */
.spellbook-hub.is-sub.hub-v2 .hub-portal:active {
  transform: translateY(0);
  filter: drop-shadow(0 5px 10px rgba(0, 0, 0, 0.72));
}
.spellbook-hub.is-sub.hub-v2 .hub-portal.is-promoted:active {
  filter:
    drop-shadow(0 0 8px rgba(122, 88, 190, 0.44))
    drop-shadow(0 0 24px rgba(122, 88, 190, 0.28))
    drop-shadow(0 5px 10px rgba(0, 0, 0, 0.72));
}
.spellbook-hub.is-sub.hub-v2 .hub-portal:active::after {
  box-shadow: inset 0 3px 9px rgba(0, 0, 0, 0.85), var(--keep-liner);
}

/* ── FOCUS-VISIBLE — a 2px highlight-gold ring offset INSIDE the notched
      corners. An `outline` would draw a rectangle around the octagon, so the
      ring is an inset shadow on the stone face, which is already clipped. ── */
.spellbook-hub.is-sub.hub-v2 .hub-portal:focus { outline: none; }
.spellbook-hub.is-sub.hub-v2 .hub-portal:focus-visible { outline: none; }
.spellbook-hub.is-sub.hub-v2 .hub-portal:focus-visible::after {
  box-shadow:
    inset 0 0 0 2px var(--keep-gold-hi, #e8c877),
    inset 0 0 0 4px rgba(0, 0, 0, 0.75),
    var(--keep-liner);
}
.spellbook-hub.is-sub.hub-v2 .hub-portal.is-promoted:focus-visible::after {
  box-shadow:
    inset 0 0 0 2px var(--keep-violet-hi, #9a7ad0),
    inset 0 0 0 4px rgba(0, 0, 0, 0.75),
    var(--keep-liner);
}

/* ── DISABLED (`.portal-locked`) — §4: "desaturated gold at 40% with the frame
      dropped to hairline-only — never just opacity:.5".
      The tile stays CLICKABLE on purpose: index.html's handler fires and toasts
      the unlock requirement, so `pointer-events:none` would be a regression.
      It simply stops responding visually like a live tile. ── */
.spellbook-hub.is-sub.hub-v2 .hub-portal.portal-locked {
  opacity: 1;                                  /* out-shouts the legacy .65 */
  filter: drop-shadow(0 6px 14px rgba(0, 0, 0, 0.55));
  cursor: not-allowed;
}
.spellbook-hub.is-sub.hub-v2 .hub-portal.portal-locked::before {
  background: rgba(138, 111, 60, 0.28);        /* hairline only — no ramp */
}
.spellbook-hub.is-sub.hub-v2 .hub-portal.portal-locked::after {
  background: linear-gradient(180deg, #141210 0%, #0b0a08 100%);
}
.spellbook-hub.is-sub.hub-v2 .hub-portal.portal-locked .hub-portal-photo {
  filter: brightness(0.42) saturate(0.15) sepia(0.30) contrast(1.02);
}
/* §4's disabled state is "desaturated gold at 40%", so the warm grade all but
   steps out of the way here — at full strength it would push the hue back into
   a panel the lock has just drained. */
.spellbook-hub.is-sub.hub-v2 .hub-portal.portal-locked .hub-portal-art::before {
  opacity: 0.10;
}
.spellbook-hub.is-sub.hub-v2 .hub-portal.portal-locked .hub-portal-icon {
  color: rgba(201, 171, 114, 0.40);
  filter: drop-shadow(0 3px 6px rgba(0, 0, 0, 0.85)) saturate(0.2);
}
.spellbook-hub.is-sub.hub-v2 .hub-portal.portal-locked .hub-portal-name {
  color: rgba(201, 171, 114, 0.40);
  text-shadow: 0 2px 0 rgba(0, 0, 0, 0.8);
}
.spellbook-hub.is-sub.hub-v2 .hub-portal.portal-locked .hub-portal-sub {
  color: rgba(154, 141, 108, 0.40);
}
/* Hover on a locked tile: acknowledged, but it does not rise and it does not
   light up. The frame warms by a hair so the click still feels received. */
.spellbook-hub.is-sub.hub-v2 .hub-portal.portal-locked:hover {
  transform: none;
  filter: drop-shadow(0 6px 14px rgba(0, 0, 0, 0.55));
}
.spellbook-hub.is-sub.hub-v2 .hub-portal.portal-locked:hover::before {
  background: rgba(138, 111, 60, 0.40);
}
.spellbook-hub.is-sub.hub-v2 .hub-portal.portal-locked:hover .hub-portal-photo {
  filter: brightness(0.42) saturate(0.15) sepia(0.30) contrast(1.02);
  transform: scale(1.02);
}
.spellbook-hub.is-sub.hub-v2 .hub-portal.portal-locked:hover .hub-portal-name {
  color: rgba(201, 171, 114, 0.40);
}
.spellbook-hub.is-sub.hub-v2 .hub-portal.portal-locked:active {
  transform: none;
  filter: drop-shadow(0 6px 14px rgba(0, 0, 0, 0.55));
}


/* ════════════════════════════════════════════════════════════════════════════
   8 · NARROWER VIEWPORTS
   The tile keeps its proportions and the column count drops. Nothing here
   changes what a tile IS — only how many fit.
   ════════════════════════════════════════════════════════════════════════════ */
@media (max-width: 1360px) {
  .spellbook-hub.is-sub.hub-v2 .hub-portal-grid {
    width: min(calc(var(--tile-w) * 3 + var(--tile-gap) * 2), 94vw);
  }
}
@media (max-width: 1040px) {
  .spellbook-hub.is-sub.hub-v2 .hub-portal-grid {
    --tile-w: 278px;
    --tile-h: 314px;
    --tile-art-h: 166px;
    width: min(calc(var(--tile-w) * 2 + var(--tile-gap)), 94vw);
  }
}
@media (max-width: 640px) {
  .spellbook-hub.is-sub.hub-v2 .hub-portal-grid {
    --tile-w: min(300px, 88vw);
    --tile-h: auto;
    --tile-art-h: 150px;
    width: min(300px, 88vw);
  }
  .spellbook-hub.is-sub.hub-v2 .hub-portal {
    height: auto;
    min-height: 300px;
  }
}


/* ════════════════════════════════════════════════════════════════════════════
   9 · REDUCED MOTION  (§4)
   Everything drops to a plain state change. NOTHING is disabled with
   `!important`, and `animation` is not touched at all — the tile-band entrance
   and its own `reduce` fallback belong to hub-transitions.css, and the
   `animation:none !important` this file used to carry was silently eating it.
   ════════════════════════════════════════════════════════════════════════════ */
@media (prefers-reduced-motion: reduce) {
  .spellbook-hub.is-sub.hub-v2 .hub-portal,
  .spellbook-hub.is-sub.hub-v2 .hub-portal::before,
  .spellbook-hub.is-sub.hub-v2 .hub-portal::after,
  .spellbook-hub.is-sub.hub-v2 .hub-portal-photo,
  .spellbook-hub.is-sub.hub-v2 .hub-portal-icon,
  .spellbook-hub.is-sub.hub-v2 .hub-portal-name,
  .spellbook-hub.is-sub.hub-v2 .hub-portal-sub,
  .spellbook-hub.is-sub.hub-v2 .hub-portal-badge {
    transition: none;
  }
  /* The lift and the art's push-in are the two motions; both go, the colour
     changes stay so hover is still legible. */
  .spellbook-hub.is-sub.hub-v2 .hub-portal:hover,
  .spellbook-hub.is-sub.hub-v2 .hub-portal:active {
    transform: none;
  }
  .spellbook-hub.is-sub.hub-v2 .hub-portal:hover .hub-portal-photo {
    transform: scale(1.02);
  }
}
