/* combat.css — combat screen: scene, HUD, rune circle, chords, flashes. */

  /* Combat screen — scene on top, rune circle filling the rest, and the open
     spell book pinned along the bottom edge with the circle nested in its V.

     The circle and the book have to line up at every viewport size, so both are
     laid out from ONE width (--board-w) and both draw in a 600-unit-wide
     viewBox — that gives them a shared scale. The rest follows from the book's
     600x240 art box: its height is 240/600 of that width, and the arena's
     bottom margin is what positions the wheel against it.

     That margin is a TANGENT distance, not a vertical one. Clearing the notch
     is not enough: the pages' top edges climb away from it at ~16°, so a wheel
     lowered until its bottom point cleared the notch would still have its
     lower-left and lower-right arcs cutting across both pages. What has to
     clear is the sloped edge itself, and the closest approach is around three
     quarters of the way out along it, not at either end.

     Solving |dist(wheel centre, page-top line)| = R + gap gives the 141/600
     below.

     WHAT THAT GAP IS NOW. The arena's viewBox is cropped to the wheel's own
     extent rather than the 600-unit box it is drawn in (see ARENA_VIEWBOX in
     screens.js), so at a given box the wheel renders 600/570 — about 5 % —
     bigger, and it is that much closer to the paper. Measured by walking both
     page-top edges in screen pixels, the margin now leaves 0,030–0,032 of the
     board width between the paper and the rune bubbles: 11 px at 360 wide,
     15 px at 480. The bubbles are the tap targets and they stay clear at every
     size — that is the constraint, and it is what the smoke test checks.

     The FAINT OUTERMOST RING no longer clears it, and that is deliberate. It
     reaches 285 where the bubbles reach 263, so its lower arcs now pass behind
     the pages — the book is `z-index: 3` over the arena, so it occludes them
     and the wheel reads as sitting behind the book rather than clipped by it.
     Trading that decoration for a circle 12 % wider is the right way round on a
     phone: the wheel is HEIGHT-limited on every one of them, so this margin and
     the height of `.hud-under` above it are the only two things deciding how big
     the thing the thumb aims at can be.

     Re-derive if either geometry moves, and re-derive BOTH — the crop and this
     margin are one calculation. Tools-side, walk the page's top edge and take
     the minimum distance rather than trusting the vertical gap, which is not
     where the two shapes come closest. */
  .combat-screen {
    position: relative;
    display: flex; flex-direction: column;
    justify-content: flex-start;
    width: 100%; height: 100%;
    /* Percentage-bearing custom properties resolve at the USE site, so this may
       only ever be consumed by width-resolved properties (`width`, and margins,
       which are resolved against the containing block's width). Feeding it to a
       `height` would resolve the 100% against the container's HEIGHT instead —
       which is why the book sizes itself with aspect-ratio below rather than
       with a height derived from here. */
    --board-w: min(100%, 520px);
  }
  .combat-screen::after {
    content: ""; position: absolute; inset: 0; pointer-events: none;
    background: rgba(229, 72, 77, 0); transition: none;
  }
  .combat-screen.wrong-flash::after { background: rgba(229, 72, 77, 0.35); }

  .scene-wrap {
    flex: 0 0 auto;
    width: 100%;
    overflow: hidden;
    background: #17131e;
    line-height: 0;
  }
  canvas.scene {
    display: block;
    image-rendering: pixelated;
  }
  /* ONE ROW, AND AS SHORT AS THE BAR ITSELF. The hero is the only thing reported
     under the scene, so the bar runs the width of the strip and its figures sit
     at the right-hand end of it — no caption line above, no "HELD" in front. The
     row that saved is height the rune circle gets instead, and the circle is
     height-limited on every phone (the wheel scales to min(box width, box
     height)), so it is height, not width, that decides how big the thing the
     thumb aims at can be. */
  .hud-under {
    flex: 0 0 auto;
    width: 100%;
    display: flex; align-items: center; gap: 8px;
    padding: 4px max(3vw, env(safe-area-inset-left)) 4px max(3vw, env(safe-area-inset-right));
    background: #1b1624;
    border-top: 1px solid #3a3550;
    border-bottom: 1px solid #3a3550;
  }
  /* The strip carries two bars now — LP and mana — stacked in one row rather
     than in two, so the height it costs the rune circle is a few pixels and not
     a whole line (see the note at the top of this file, and patchCombatContinuous).
     The mana bar is the thinner of the two on purpose: it is read by counting
     its divisions, not by measuring its length. */
  .hud-bars {
    flex: 1 1 auto; min-width: 0;
    display: flex; flex-direction: column; gap: 2px;
  }
  .mana-track {
    /* One segment = one cast of the page the book is open at. --seg is that
       page's price as a share of the whole pool, set per frame in
       patchCombatContinuous; the gradient rules a 2px divider at the end of
       every segment. `calc` inside a colour-stop position keeps the last,
       partial segment honest — it simply gets cut short by the bar's end. */
    --seg: 100%;
    position: relative;
    width: 100%; height: 6px;
    background: #241f2f; border: 1px solid #3a3550; border-radius: 4px; overflow: hidden;
    transition: opacity 160ms linear;
  }
  /* THE DIVISIONS ARE RULED TWICE, and that is not belt and braces: a segment
     has to be countable on the SPENT part of the bar as well as the filled one,
     or the strip only ever says how many casts you have and never how many the
     pool holds. A dark tick disappears on the dark empty track and a light one
     disappears on the light fill — so the light ruling goes UNDER the fill
     (where only the empty stretch can show it) and the dark ruling OVER it. */
  .mana-track::before,
  .mana-track::after {
    content: ""; position: absolute; inset: 0; pointer-events: none;
  }
  .mana-track::before {
    z-index: 0;
    background: repeating-linear-gradient(90deg,
      rgba(0,0,0,0) 0, rgba(0,0,0,0) calc(var(--seg) - 2px),
      #453f5c calc(var(--seg) - 2px), #453f5c var(--seg));
  }
  .mana-track::after {
    z-index: 2;
    background: repeating-linear-gradient(90deg,
      rgba(0,0,0,0) 0, rgba(0,0,0,0) calc(var(--seg) - 2px),
      rgba(12,10,20,0.85) calc(var(--seg) - 2px), rgba(12,10,20,0.85) var(--seg));
  }
  .mana-fill {
    position: relative; z-index: 1;
    height: 100%;
    background: linear-gradient(90deg, #3f56b8, #7f9dff);
    transition: width 120ms linear;
  }
  /* Not enough in the pool for the open page: the strip steps back rather than
     shouting. There is nothing to DO about it — the shape will go off by itself
     — so it must not read as an error. */
  .mana-track.short { opacity: 0.72; }
  /* …and the chords the player already drew breathe with it, so the wait reads
     as the staff gathering rather than as a tap that didn't register. */
  .combat-screen.gathering .chord,
  .combat-screen.gathering .chord-glow { animation: mana-gather 900ms ease-in-out infinite; }
  /* A finished shape is waiting on the pool. This is the one mana state that
     has to catch the eye, because it is the game saying the player's part is
     already done. */
  .mana-track.gathering .mana-fill { animation: mana-gather 900ms ease-in-out infinite; }
  @keyframes mana-gather {
    0%, 100% { filter: brightness(1); }
    50%      { filter: brightness(1.75); }
  }
  .hp-figures {
    flex: 0 0 auto;
    font-size: clamp(11px, 3vw, 13px); line-height: 1; color: #efeaff;
    font-variant-numeric: tabular-nums; white-space: nowrap;
  }
  .hp-track {
    flex: 1 1 auto;
    width: 100%; height: 10px;
    background: #241f2f; border: 1px solid #3a3550; border-radius: 5px; overflow: hidden;
  }
  .hp-fill { height: 100%; transition: width 180ms linear; }
  .hp-track.hero .hp-fill { background: linear-gradient(90deg, #2f9e57, #5ecf8f); }
  .coin { color: #f2c14e; }
  .gold-line { font-size: clamp(13px, 3.6vw, 17px); color: #f2c14e; font-weight: 700; }

  svg.arena {
    flex: 1 1 auto;
    min-height: 0;
    width: var(--board-w);
    /* Bottom-aligned (xMidYMax), so the wheel always sits down against the book
       instead of floating in the middle of whatever height is left over. */
    margin-bottom: calc(var(--board-w) * 0.235);
    display: block;
    /* Runes are dragged with the pointer — don't let a drag scroll/pan the page */
    touch-action: none;
    -webkit-user-select: none; user-select: none;
  }

  /* ---- Spell book -------------------------------------------------------
     An open book along the bottom edge, tilted toward the player: two page
     planes angled up and out so their top edges make a V, spine in the middle,
     both lower corners — the gutter furthest of all — running off the bottom of
     the screen. Geometry is in spellbook.js; this is only the palette and the
     interaction states. */
  .spellbook {
    position: absolute; bottom: 0; left: 50%; transform: translateX(-50%);
    /* Height comes from the art box's own 600x240 ratio, so it always matches
       the width the wheel is laid out from (see --board-w). */
    width: var(--board-w); height: auto; aspect-ratio: 600 / 240;
    display: block; z-index: 3;
    /* The whole book takes the drag that turns its pages; the empty upper
       corners of the box, which the rune wheel overlaps into, do not. */
    pointer-events: none;
    touch-action: none;
    /* An SVG drop shadow, not a CSS one: a CSS drop-shadow's colour is a
       paint, and a phone's dark mode inverts it into a bright halo round the
       whole book. feDropShadow's flood-color is a filter constant (see
       src/dark-paint.js). */
    filter: url(#bookShadow);
  }
  /* ---- Boards ---- A board is leather over wood: grain, a bevel catching the
     light along the cut head edge, and two blind-tooled fillets run round
     inside it. */
  .bk-cover { fill: #3d2b1c; stroke: none; filter: url(#fl-3d2b1c); }
  .bk-cover-edge { fill: none; stroke: #150f0a; stroke-width: 2; stroke-linejoin: round; filter: url(#fl-150f0a); }
  .bk-grain { filter: url(#bkGrain); opacity: 0.5; mix-blend-mode: multiply; pointer-events: none; }
  .bk-cover-bevel { fill: none; stroke: #7d5c3c; stroke-width: 1.2; opacity: 0.55; filter: url(#fl-7d5c3c); }
  .bk-tool { fill: none; stroke: #241708; stroke-width: 1.4; opacity: 0.6; filter: url(#fl-241708); }
  .bk-tool.thin { stroke-width: 0.8; opacity: 0.4; }

  /* ---- The text block's fore-edge ---- Fill is set per sliver (it darkens with
     depth into the block), so it must NOT be declared here — a CSS `fill` would
     override the presentation attribute and flatten the fan. */
  .bk-stack { stroke: none; }

  /* ---- Paper ---- The leaf's fill is a per-side gradient (defined in the
     book's own <defs>), so light runs from the lit outer edge into the shadowed
     gutter instead of the whole page being one flat colour. Over that: a fibre
     wash, a little foxing, the gutter shadow and the fore-edge sheen — all
     clipped to the leaf, all static, all painted once. */
  .bk-leaf { fill: url(#bkPaperL); stroke: none; }
  .bk-paper { fill: #f3eacd; pointer-events: none; }
  .bk-page[data-side="1"] .bk-leaf { fill: url(#bkPaperR); }
  /* The cut edge of the leaf, carried by its own path so it can be flooded
     apart from the paper (see src/dark-paint.js). */
  .bk-leaf-edge {
    fill: none; stroke: #a29673; stroke-width: 1.2; stroke-linejoin: round;
    filter: url(#fl-a29673);
    transition: stroke 140ms linear;
  }
  .bk-fibre { filter: url(#bkFibre); opacity: 0.55; mix-blend-mode: multiply; pointer-events: none; }
  .bk-foxing { fill: #a3763f; opacity: 0.07; mix-blend-mode: multiply; pointer-events: none; filter: url(#fl-a3763f); }
  .bk-gutter, .bk-foreedge { pointer-events: none; }
  .bk-cut {
    fill: none; stroke: #fffcf0; stroke-width: 1.1; opacity: 0.55;
    stroke-linecap: round; pointer-events: none;
    filter: url(#fl-fffcf0);
  }
  /* Sealed pages keep the paper's shading and are darkened over the top of it,
     so they read as the same book rather than as a different material.

     The wash is FLOODED rather than filled (see src/dark-paint.js): left as a plain dark fill, a phone's dark mode inverts it and
     the sealed page comes out the brightest thing in the book — exactly
     backwards from what it means. The rim is a separate path because a flood
     carries one colour and cannot do both.
     The 0.62 sits on the GROUP, not on each path: fading them separately would
     composite the rim over an already-faded wash instead of over the page, and
     the outline would darken. One element's worth of fading, as before. */
  .bk-seal-wash { opacity: 0.62; }
  .bk-sealed { fill: #211a35; filter: url(#fl-211a35); stroke: none; }
  .bk-sealed-rim { fill: none; stroke: #4a4360; stroke-width: 1.2; }
  /* The active page's border: a crisp line over a blurred copy of itself. One
     blurred stroke on its own reads as a fuzzy halo with no edge to it. */
  .bk-halo { fill: none; stroke-width: 5; opacity: 0.3; filter: url(#bkGlow); }
  .bk-lit { fill: none; stroke-width: 2; opacity: 0.9; }

  /* ---- What is written on the leaf ---- Everything below rides the page frame
     (one matrix, see pageFrame in spellbook.js), so the ruling, the script and
     the miniature all lie on the same plane. That shared plane is most of what
     sells the tilt: ruled lines seen at an angle read as a surface. */
  .bk-margin { fill: none; stroke: #7d6440; stroke-width: 1; opacity: 0.4; filter: url(#fl-7d6440); }
  .bk-rule { stroke-width: 1.3; opacity: 0.6; stroke-linecap: round; }
  .bk-rule.thin { stroke-width: 0.7; opacity: 0.4; }
  /* NOT flooded, unlike the rest of the page. The title is one element whose
     letters and outline are painted together by `paint-order`, and splitting
     them so each could take its own flood changed how the glyphs render — the
     title came out visibly lighter for everyone. A page title that shifts under
     a phone's dark mode is the smaller price. */
  .bk-name {
    font-family: var(--display-font);
    font-weight: 700; letter-spacing: 0.4px;
    paint-order: stroke; stroke: rgba(24, 18, 10, 0.45); stroke-width: 2.2px;
  }
  /* The body of the page: ~500 glyphs emitted into ONE path (see pageScript), so
     a full page of writing costs a single element and a single stroke. */
  .bk-script {
    fill: none; stroke: #5c4526; stroke-width: 1.35; opacity: 0.5;
    stroke-linecap: round; stroke-linejoin: round; pointer-events: none;
    filter: url(#fl-5c4526);
  }
  .bk-initial { fill: none; stroke-width: 2.4; stroke-linecap: round; stroke-linejoin: round; }
  .bk-initial-box { fill: #b3924e; stroke: none; opacity: 0.112; filter: url(#fl-b3924e); }
  .bk-initial-rim { fill: none; stroke: #b3924e; stroke-width: 0.9; opacity: 0.8; filter: url(#fl-b3924e); }
  /* A sealed page is written like any other — the page exists, you simply can't
     read it yet — so sealing only drains the ink. */
  /* A sealed page is written in a different ink, so it floods to a different
     colour — a flood carries one, and the one on .bk-script is the open
     page's. */
  .bk-page.locked .bk-script { stroke: #6b6389; opacity: 0.5; filter: url(#fl-6b6389); }
  .bk-page.locked .bk-initial-box { stroke: #5b5170; fill: none; }

  /* ---- The page's great rune ---- Laid UNDER the script, which runs straight
     over it: it is the page's ground, not a picture set into a hole in it. Both
     rings are projected polygons, so they arrive as the ellipses you'd see on a
     page tilted away from you rather than as flat circles. */
  .bk-sigil { fill: none; stroke-linecap: round; stroke-linejoin: round; pointer-events: none; }
  .bk-sigil-rune { stroke-width: 4; opacity: 0.38; }
  .bk-sigil-ring { stroke-width: 1.8; opacity: 0.3; }
  .bk-sigil-ring.thin { stroke-width: 1; opacity: 0.22; }
  .bk-sigil-tick { stroke-width: 1.6; opacity: 0.26; }
  .bk-page.active .bk-sigil-rune { opacity: 0.48; }
  .bk-page.locked .bk-sigil { opacity: 0.7; }

  /* Wax is dark red, which a phone's dark mode turns pink. Body and edge are
     flooded separately — one flood, one colour. The inner rim is light enough
     that the filter leaves it alone, so it stays an ordinary stroke. */
  .bk-wax { fill: #7c2434; filter: url(#fl-7c2434); stroke: none; }
  .bk-wax-edge { fill: none; stroke: #4d1420; stroke-width: 1.4; filter: url(#fl-4d1420); }
  .bk-wax-shadow { fill: #120c1c; filter: url(#fl-120c1c); opacity: 0.5; }
  .bk-wax-rim { fill: none; stroke: #a5455a; stroke-width: 2; opacity: 0.85; }
  .bk-sealmark { font-family: var(--display-font); font-size: 22px; font-weight: 700; fill: #e5b3bc; opacity: 0.8; }
  .bk-ribbon { opacity: 0.9; }
  /* NOT flooded, unlike the wax and the sealed wash. A filter region is a
     percentage of the GEOMETRIC bounding box, and the spine is a straight line
     — zero width — so the region collapses and clips the stroke away entirely.
     Only shapes with an area to measure can be flooded. This one stays an
     ordinary stroke and a phone's dark mode is welcome to it: it is a hairline
     down the gutter, and a light one there reads as the fold catching light. */
  .bk-spine { stroke: #100c08; stroke-width: 5; stroke-linecap: round; opacity: 0.85; }

  /* ---- The spell playing ON TOP of the page --------------------------------
     Not an illustration on the page: the effect plays over the whole leaf, on a
     wash of its own coloured light falling on the paper under it. The page the
     book is open at plays at full strength; the facing page is held well back,
     or two full-page effects at once would bury the writing on both. */
  /* NEVER put a CSS animation on an element filled with a soft gradient, or on
     a group holding one. Animating it promotes the element to its own
     composited layer, and the radial falloff is rasterised to that layer's
     BOUNDING BOX — every pool of light on the page comes out as a hard-edged
     rectangle. The pools and the rime are therefore static; their motion comes
     from the flames and crystals standing in them, which have flat fills and
     animate freely. */
  .bk-fx-pool, .bk-fx-rime, .bk-wash, .bk-fx { pointer-events: none; }
  .bk-fx-rime { opacity: 0.45; }
  .bk-wash { opacity: 0.55; }
  .bk-page:not(.active) .bk-fx { opacity: 0.62; }
  .bk-page:not(.active) .bk-wash { opacity: 0.3; }
  .bk-fx-glyph { fill: none; stroke-width: 1.6; opacity: 0.5; stroke-linecap: round; }

  /* Every moving part is a CSS loop — no JS driver, nothing per frame. Each
     class has ONE duration; the matching period is passed to `D()` in
     spellbook.js, which hands back a negative animation-delay that both staggers
     an element against its siblings and picks the loop up at the phase the game
     clock has already reached, so rebuilding the book doesn't restart the motion.
     Anything scaled or spun needs fill-box, or SVG works about the viewBox's
     origin instead of the element's own centre.

     Note the two ORIGINS below. A flame is hinged where it meets the paper, so
     it flickers about its base; everything else works about its middle. Getting
     that wrong makes a flame slide around instead of dancing on the spot. */
  .bk-fx-flame, .bk-fx-ember, .bk-fx-arc, .bk-fx-node, .bk-fx-form,
  .bk-fx-drop, .bk-fx-drop-in, .bk-fx-scorch, .bk-fx-ward,
  .bk-fx-turn, .bk-fx-float, .bk-fx-rise {
    transform-box: fill-box; transform-origin: 50% 50%;
    animation-iteration-count: infinite; animation-timing-function: ease-in-out;
  }
  .bk-fx-flame, .bk-fx-ember, .bk-fx-rise { transform-origin: 50% 100%; }

  .bk-fx-flame { animation-name: bkFlame; animation-duration: 900ms; }
  .bk-fx-ember { animation-name: bkEmber; animation-duration: 2600ms; animation-timing-function: linear; }
  .bk-fx-arc   { animation-name: bkArc;   animation-duration: 2400ms; animation-timing-function: steps(1, end); }
  .bk-fx-node  { animation-name: bkNode;  animation-duration: 2400ms; }
  .bk-fx-form  { animation-name: bkForm;  animation-duration: 4200ms; }
  .bk-fx-drop     { animation-name: bkDrop;    animation-duration: 2000ms; animation-timing-function: ease-in; }
  .bk-fx-drop-in  { animation-name: bkDropIn;  animation-duration: 2000ms; animation-timing-function: ease-in; }
  .bk-fx-scorch   { animation-name: bkScorch;  animation-duration: 2000ms; animation-timing-function: ease-out; }
  .bk-fx-ward  { animation-name: bkWard;  animation-duration: 3000ms; animation-timing-function: ease-out; }
  .bk-fx-turn  { animation-name: bkSpin;  animation-duration: 24000ms; animation-timing-function: linear; }
  .bk-fx-float { animation-name: bkFloat; animation-duration: 3200ms; }
  .bk-fx-rise  { animation-name: bkRise;  animation-duration: 2800ms; animation-timing-function: linear; }

  /* A tongue of flame: it leans and stretches, it does not simply throb. The
     four steps are deliberately uneven so no two flames on a page ever look
     like they are keeping time with each other. */
  @keyframes bkFlame {
    0%, 100% { transform: scale(1, 1) skewX(0deg); }
    22% { transform: scale(0.9, 1.16) skewX(-5deg); }
    46% { transform: scale(1.08, 0.9) skewX(3deg); }
    71% { transform: scale(0.95, 1.09) skewX(-2deg); }
  }
  @keyframes bkEmber { 0% { transform: translateY(-30px) scale(0.9); opacity: 0; }
                       18% { opacity: 0.95; }
                       100% { transform: translateY(-96px) scale(0.3); opacity: 0; } }
  /* An arc is struck, not faded in: on for a beat, gone for the rest. */
  @keyframes bkArc  { 0% { opacity: 1; } 10% { opacity: 0.55; } 14%, 100% { opacity: 0; } }
  @keyframes bkNode { 0% { transform: scale(1.5); opacity: 0.9; }
                      14% { transform: scale(1); opacity: 0.25; }
                      30%, 100% { opacity: 0; } }
  /* Ice: grows out of nothing, holds, then thaws away. The hold is most of the
     cycle — crystals that never stop moving read as sparkle, not as frost. */
  @keyframes bkForm { 0% { transform: scale(0.15) rotate(-25deg); opacity: 0; }
                      18% { transform: scale(1) rotate(0deg); opacity: 1; }
                      66% { transform: scale(1) rotate(0deg); opacity: 1; }
                      100% { transform: scale(0.9) rotate(6deg); opacity: 0; } }
  /* The shadow a falling rock throws on the paper: wide and faint while it is
     high up, tight and dark as it lands. This is the whole height cue — the
     rock's own fall says nothing about how far away it started. */
  @keyframes bkDrop { 0% { transform: scale(2.4); opacity: 0; }
                      14% { opacity: 0.1; }
                      82% { transform: scale(1); opacity: 0.42; }
                      88%, 100% { transform: scale(1); opacity: 0; } }
  @keyframes bkDropIn { 0% { transform: translateY(-150px); opacity: 0; }
                        14% { opacity: 1; }
                        82% { transform: translateY(0); opacity: 1; }
                        86%, 100% { transform: translateY(0); opacity: 0; } }
  @keyframes bkScorch { 0%, 82% { transform: scale(0.2); opacity: 0; }
                        88% { transform: scale(1); opacity: 0.9; }
                        100% { transform: scale(1.6); opacity: 0; } }
  @keyframes bkSpin  { to { transform: rotate(360deg); } }
  @keyframes bkWard  { 0% { transform: scale(0.82); opacity: 0.5; }
                       100% { transform: scale(1.14); opacity: 0; } }
  @keyframes bkFloat { 0%, 100% { transform: translateY(4px); } 50% { transform: translateY(-6px); } }
  /* A mote leaving the paper: it lifts off the spot it came from and thins out
     as it goes, rather than fading on the spot. */
  @keyframes bkRise { 0% { transform: translateY(0) scale(0.5); opacity: 0; }
                      20% { opacity: 1; }
                      100% { transform: translateY(-84px) scale(1.1); opacity: 0; } }

  /* A cast lights its own page for a beat. The class is toggled on the book by
     patchCombatContinuous while a freshly queued effect is still running, so it
     costs one classList call per frame rather than a rebuild. */
  .bk-fx-cast { transform-box: fill-box; transform-origin: 50% 50%; transition: transform 120ms ease-out; }
  .spellbook.casting .bk-page.active .bk-fx-cast { transform: scale(1.1); }
  .spellbook.casting .bk-page.active .bk-wash { opacity: 0.95; }

  /* Every leaf takes the pointer — a tap selects the spell, a drag across the
     book turns the page (see attachSpellbookDrag). Locked leaves are draggable
     too: you can leaf past a sealed page, you just can't cast from it. */
  /* Paper turning away from the light. Driven straight off the drag by setTurn,
     so it tracks the finger; a transition here would lag the page it shades. */
  .bk-shade { fill: #0d0917; pointer-events: none; }
  /* An under-leaf is scenery for the length of a page turn — never a tap
     target, and never in front of the leaf that is actually open. */
  .bk-page.under { pointer-events: none; }

  .bk-page:not(.under) { pointer-events: auto; }
  /* The leaf being carried over mid-turn is in flight, not in the spread: it is
     only in the SVG for the length of the drag, so it must never take a tap. */
  .bk-turn { pointer-events: none; }
  .bk-page[data-act] { cursor: pointer; }
  .bk-page[data-act]:active .bk-leaf-edge { stroke: #fff3c4; stroke-width: 2; filter: none; }
  /* The turning page springs back if the drag falls short of the threshold;
     while the finger is down it has to track it exactly, so no transition. */
  .bk-page { transition: transform 170ms ease-out; transform-origin: 0 0; }
  .spellbook.dragging .bk-page { transition: none; }

  /* Motion here is ambience, not information — every loop is safe to stop. */
  @media (prefers-reduced-motion: reduce) {
    .spellbook [class*="bk-fx-"] { animation: none !important; }
  }
  /* The static arcane wheel is a baked high-fidelity image; live SVG on top
     draws only what changes: word bubbles, chords, filigree, sparks, and the
     rotating inner script ring */
  .glyph {
    fill: none; stroke: #7ff0ed; stroke-width: 1.7; opacity: 0.6;
    stroke-linecap: round; stroke-linejoin: round;
  }
  .inner-band .glyph { stroke-width: 1.4; }
  .spark { fill: url(#sparkGrad); }
  .rune .halo { opacity: 0; fill: #f2c14e; transition: opacity 150ms linear; pointer-events: none; }
  .rune.selected .halo { opacity: 0.4; }
  .rune.matched .halo { opacity: 0.22; fill: #5ecf8f; }
  /* Word bubbles are transparent wells over the baked sockets: a whisper of
     dark fill for legibility, state shown by ring tint.

     The wash and the ring are two circles, not one filled-and-stroked circle.
     The wash is FLOODED to its colour (see src/dark-paint.js)
     because a phone's dark mode inverts a dark fill and turns every well into
     a bright disc, which reads as six runes already lit. A flood carries one
     colour, so the ring has to be its own element — and it wants to stay an
     ordinary stroke anyway, since it is light and the filter leaves it be.
     The wash is opaque with its transparency on `opacity` rather than in an
     rgba() fill — a flood composited "in" is multiplied by the source's alpha,
     so an rgba fill would count its own alpha twice and wash out. */
  .rune .well { stroke: none; pointer-events: none; }
  .rune.unmatched .well { fill: #080d12; opacity: 0.30; filter: url(#fl-080d12); }
  .rune.selected .well { fill: #f2c14e; opacity: 0.16; filter: url(#fl-f2c14e); }
  .rune.matched .well { fill: #5ecf8f; opacity: 0.14; filter: url(#fl-5ecf8f); }

  /* `pointer-events: all` is what makes the rune tappable, and it is load-
     bearing: the ring is the only element in the group that takes a press (the
     well, the halo and the word are all `none`), and splitting the wash off it
     left it with `fill: none`. An unpainted fill is not hit-tested by default,
     so the tap target silently shrank to the 1,5 px ring itself and the whole
     circle went dead to a thumb. `all` hit-tests the fill region whether it is
     painted or not, which restores the disc the rune had when it was one
     filled-and-stroked circle. */
  .rune .body {
    fill: none; stroke-width: 1.5; cursor: pointer;
    pointer-events: all; transition: stroke 100ms linear;
  }
  .rune.unmatched .body { stroke: rgba(127, 244, 241, 0.35); }
  .rune.selected .body { stroke: #ffe9b0; stroke-width: 2.5; }
  .rune.matched .body { stroke: rgba(143, 231, 180, 0.8); }
  /* The word is written twice: a dark outline underneath, then the letters over
     it. It used to be one element with `paint-order: stroke`, but that outline
     is what keeps the word legible against the wheel's own art, and a phone's
     dark mode inverted it into a white glow that swallowed the letters. Written
     as a separate element it can be flooded (see src/dark-paint.js) while the
     letters — light enough that the filter leaves them be — stay ordinary text. */
  .rune text {
    font-family: var(--display-font);
    font-size: 19px; letter-spacing: 0.5px; font-weight: 700;
    text-anchor: middle; dominant-baseline: central;
    pointer-events: none;
  }
  .rune .word-halo {
    fill: #080d12; stroke: #080d12; stroke-width: 3px; opacity: 0.9;
    filter: url(#fl-080d12);
  }
  .rune .word { fill: #e8fffe; stroke: none; }
  .rune.selected .word { fill: #ffe9b0; }
  .rune.matched .word { fill: #b9f2d2; }
  /* Wrong pair: the whole rune circle flares red for a beat before it re-deals.
     Higher specificity than the per-state rules above so every rune goes red. */
  #wheel-img { transition: filter 120ms linear; }
  .combat-screen.rune-flash #wheel-img { filter: sepia(1) saturate(7) hue-rotate(-22deg) brightness(1.05); }
  .combat-screen.rune-flash .rune .well { fill: #e5484d; opacity: 0.34; filter: url(#fl-e5484d); }
  .combat-screen.rune-flash .rune .body { stroke: #ff6470; stroke-width: 2.5; }
  .combat-screen.rune-flash .rune .word { fill: #ffdadf; }
  .combat-screen.rune-flash .rune .halo { opacity: 0.55; fill: #ff5560; }
  .combat-screen.rune-flash .chord,
  .combat-screen.rune-flash .chord-glow { stroke: #ff6470; }
  /* The line trailing from a held rune to the pointer while dragging a pair */
  .drag-line {
    stroke: #ffe9b0; stroke-width: 3; stroke-linecap: round;
    stroke-dasharray: 6 8; opacity: 0; pointer-events: none;
    filter: url(#softGlow);
  }
  .drag-line.active { opacity: 0.85; }
  .chord-glow { stroke: #4de3e0; stroke-width: 9; stroke-linecap: round; opacity: 0.3; }
  .chord { stroke: #8ff7f3; stroke-width: 3.5; stroke-linecap: round; }
  .chord.flash { stroke: #ffffff; stroke-width: 6; }
  .chord-glow.flash { stroke: #ffffff; opacity: 0.6; }
  /* Filigree sub-lines for every possible chord: pulse faintly, burst white
     on a correct match, then stay lit alongside their main line */
  .sub-group { stroke: #4de3e0; }
  .sub-group.burst { stroke: #ffffff; }
  .sub-group * { fill: none; stroke-linecap: round; }

