:root {
    color-scheme: light;
    --page: #f4f1ea;
    --ink: #202026;
    --muted: #706f79;
    --line: #d9d2c4;
    --panel: #fffaf0;
    --tile-1: #f1c75b;
    --tile-2: #5fb9a8;
    --tile-3: #ee7d6b;
    --tile-4: #7c8ed9;
    --tile-5: #b56ac8;
    --tile-6: #4f9c68;
    --tile-7: #d56f98;
    --accent: #e8a13c;
    --accent-soft: #f6d99a;
    --good: #4f9c68;
    --good-soft: #d9efdd;
    --shadow: 0 18px 40px rgb(39 35 28 / 16%);
    --card-shadow: 0 10px 22px rgb(36 31 25 / 8%), inset 0 1px 0 rgb(255 255 255 / 70%);
    /*
     * Canvas Scaler, ported from Unity: "Scale With Screen Size" against a
     * 620x1100 reference (the portrait shape this game is laid out for), Screen
     * Match Mode "Expand" — the scale is the smaller of the two axis ratios, so
     * the UI never overflows either edge and keeps the same share of the screen
     * at any window size.
     *
     * `--ui-px` is ONE REFERENCE PIXEL. Any size written as
     * calc(N * var(--ui-px)) is authored at the reference resolution and scales
     * from there, instead of staying a fixed pixel size while the art around it
     * grows or shrinks. Drives the whole HUD, board and scene dialogue.
     *
     * `--ui-scale` is a manual multiplier on top of that — the console's
     * setUiScale() writes to it, so the whole UI can be nudged bigger or smaller
     * live without touching the reference resolution itself.
     *
     * It is also where the desktop/touch split lives. Touch is the wider default
     * (2): a phone is held close and its controls have to clear a fingertip. A
     * desktop sits at arm's length behind a mouse, so the same UI at 2 reads
     * oversized there and crowds the top row — hence 1.25 below. main.js reads
     * this computed value rather than holding its own copy, so getUiScale() stays
     * truthful on either device.
     */
    --ui-ref-width: 620;
    --ui-ref-height: 1100;
    --ui-scale: 2;
    /* The board's width cap in reference pixels (see .board-wrap). Its own knob
       rather than a share of --ui-scale: on a phone the board is the screen and
       should fill it, while on desktop it sat over most of the room art, so the
       two want different amounts of board without the HUD changing size. */
    --board-max: 524;
    --ui-px: calc(min(100vw / var(--ui-ref-width), 100svh / var(--ui-ref-height)) * var(--ui-scale));
    --score-popup-duration: 860ms;
    --invalid-duration: 240ms;
    --tile-destroy-duration: 170ms;
    --tile-hit-duration: 170ms;
}

/* Desktop: a precise pointer that can hover — a mouse or trackpad, never a
   fingertip. Touch devices report `coarse`/`none` and keep the 2 above. Matching
   on input rather than width is what keeps a landscape phone (as wide as a small
   laptop) on the touch scale. A touchscreen laptop still matches here, correctly:
   its primary input is the mouse. */
@media (hover: hover) and (pointer: fine) {
    :root {
        --ui-scale: 1.25;
        --board-max: 430;
    }
}

* {
    box-sizing: border-box;
}

html,
body {
    width: 100%;
    height: 100%;
    overflow: hidden;
    /* Nothing here is a document to read and copy — it is a game surface that is
       dragged, pressed and held. Selection is switched off once at the root and
       inherited by everything, so a drag across the board or a long press on a
       line never paints the blue selection over the art. The touch pair kills the
       same effects on mobile: the grey/blue flash on tap and the long-press
       callout on images. */
    -webkit-user-select: none;
    user-select: none;
    -webkit-touch-callout: none;
    -webkit-tap-highlight-color: transparent;
}

body {
    margin: 0;
    font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
    color: var(--ink);
    background:
        linear-gradient(135deg, rgb(255 255 255 / 74%), rgb(255 255 255 / 0%) 42%),
        var(--page);
}

button {
    font: inherit;
}

/* Language-wait cover; see #bootScreen in index.html. Above everything. */
.boot-screen {
    position: fixed;
    inset: 0;
    z-index: 2147483647;
    display: grid;
    place-items: center;
    background:
        radial-gradient(circle at 50% 45%, rgb(255 255 255 / 80%), rgb(255 255 255 / 0%) 60%),
        var(--page);
}

.boot-spinner {
    width: 44px;
    height: 44px;
    border: 4px solid var(--accent-soft);
    border-top-color: var(--accent);
    border-radius: 50%;
    animation: boot-spin 800ms linear infinite;
}

@keyframes boot-spin {
    to {
        transform: rotate(360deg);
    }
}

.game-shell {
    width: min(100%, calc(620 * var(--ui-px)));
    height: 100svh;
    min-height: 0;
    margin: 0 auto;
    padding:
        max(calc(8 * var(--ui-px)), env(safe-area-inset-top))
        calc(12 * var(--ui-px))
        max(calc(8 * var(--ui-px)), env(safe-area-inset-bottom));
    display: grid;
    grid-template-rows: auto minmax(0, 1fr);
    align-content: stretch;
    gap: calc(8 * var(--ui-px));
    overflow: hidden;
}

.icon-button {
    min-width: calc(54 * var(--ui-px));
    height: calc(36 * var(--ui-px));
    padding: 0 calc(10 * var(--ui-px));
    border: 1px solid var(--line);
    border-radius: calc(10 * var(--ui-px));
    color: var(--ink);
    background: linear-gradient(180deg, #ffffff, #fbf5ea);
    box-shadow: 0 6px 14px rgb(36 31 25 / 8%), inset 0 1px 0 rgb(255 255 255 / 80%);
    cursor: pointer;
    font-size: calc(13 * var(--ui-px));
    font-weight: 800;
    touch-action: manipulation;
    transition: transform 100ms ease, box-shadow 100ms ease, filter 100ms ease;
}

.icon-button:hover {
    transform: translateY(-1px);
    filter: brightness(1.02);
}

.icon-button:active {
    transform: translateY(1px);
    box-shadow: 0 3px 8px rgb(36 31 25 / 10%);
}

.icon-button:disabled {
    opacity: 0.45;
    cursor: default;
}

.icon-button.primary {
    color: #fffaf0;
    border-color: #202026;
    background: #202026;
}

.icon-button.is-active {
    color: #fffaf0;
    border-color: #3f7d90;
    background: #3f7d90;
}

/* Destructive action (e.g. reset progress): warm-neutral until pressed. */
.icon-button.danger {
    color: #a23a2a;
    border-color: rgb(193 80 60 / 45%);
    background: linear-gradient(180deg, #fff6f2, #fbe6df);
}

.icon-button.danger:hover {
    filter: brightness(1.01) saturate(1.05);
}

/* Premium HUD: one warm strip holding identity + nav, hero score, goal bars.
   Sized in reference pixels (see --ui-px) like the scene dialogue, so the whole
   strip shrinks or grows with the art around it instead of holding a fixed size
   while the scene scales past it. */
.hud {
    /* Hugs its content (world-ring + gift/menu — .hud-title is hidden, see
       below) instead of stretching the full shell width: with the title gone
       there's nothing to fill that space, and stretching just left a bare
       gap between the ring and the nav buttons. Staying narrow is also what
       keeps the top row working at squarer aspect ratios — .corner-stats and
       .goals are both fixed to the viewport's top corners with their own
       fixed widths, so a centred strip this size has room between them
       instead of running under either one. */
    width: fit-content;
    justify-self: center;
    display: grid;
    gap: calc(8 * var(--ui-px));
    padding: calc(10 * var(--ui-px)) calc(11 * var(--ui-px)) calc(11 * var(--ui-px));
    border-radius: calc(20 * var(--ui-px));
    background: linear-gradient(160deg, #faf6ee, #f1ece0);
    box-shadow: inset 0 1px 0 rgb(255 255 255 / 80%), 0 8px 20px rgb(39 35 28 / 8%);
}

/* Identity strip doubles as navigation: World ring, level title, Gifts + menu. */
.hud-identity {
    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: calc(10 * var(--ui-px));
    padding: calc(8 * var(--ui-px)) calc(10 * var(--ui-px));
    border: 1px solid #ece0c8;
    border-radius: calc(14 * var(--ui-px));
    background: var(--panel);
    box-shadow: 0 4px 12px rgb(36 31 25 / 6%), inset 0 1px 0 #fff;
}

/* World progress ring: conic dial (--dial 0..1) around a scene emblem. */
.world-ring {
    position: relative;
    width: calc(40 * var(--ui-px));
    height: calc(40 * var(--ui-px));
    flex: none;
    padding: 0;
    border: 0;
    border-radius: 50%;
    background: none;
    cursor: pointer;
    touch-action: manipulation;
    transition: transform 100ms ease;
}

.world-ring:hover {
    transform: translateY(-1px);
}

.world-ring:active {
    transform: scale(0.96);
}

.world-ring-dial {
    position: absolute;
    inset: 0;
    border-radius: 50%;
    background: conic-gradient(var(--accent) calc(var(--dial, 0) * 360deg), #ece0c8 0);
}

.world-ring-emblem {
    position: absolute;
    inset: calc(4 * var(--ui-px));
    border-radius: 50%;
    background: var(--panel);
    display: grid;
    place-items: center;
    font-size: calc(16 * var(--ui-px));
}

.world-ring-badge {
    position: absolute;
    bottom: calc(-3 * var(--ui-px));
    right: calc(-3 * var(--ui-px));
    padding: calc(1 * var(--ui-px)) calc(4 * var(--ui-px));
    border: 1.5px solid var(--panel);
    border-radius: 99px;
    background: var(--accent);
    color: #fff;
    font-size: calc(8 * var(--ui-px));
    font-weight: 800;
    font-variant-numeric: tabular-nums;
}

/* The level title lives in the goals panel's own heading (see .goals-heading)
   now that the panel is always on screen, so the HUD strip's copy of it would
   just be a duplicate — kept in the DOM (main.js still writes it) in case a
   future layout wants it back, but never shown. */
.hud-title {
    display: none;
}

.hud-kicker {
    display: block;
    color: #b0824a;
    font-size: calc(10 * var(--ui-px));
    font-weight: 800;
    letter-spacing: 0.06em;
    text-transform: uppercase;
    white-space: nowrap;
    overflow: hidden;
    text-overflow: ellipsis;
}

.hud-level-name {
    display: block;
    font-size: calc(14 * var(--ui-px));
    font-weight: 800;
    color: var(--ink);
    white-space: nowrap;
    overflow: hidden;
    text-overflow: ellipsis;
}

.hud-nav {
    display: flex;
    gap: calc(6 * var(--ui-px));
    flex: none;
}

.hud-icon-btn {
    position: relative;
    width: calc(36 * var(--ui-px));
    height: calc(36 * var(--ui-px));
    padding: 0;
    border: 1px solid #e4d8c2;
    border-radius: calc(11 * var(--ui-px));
    background: linear-gradient(180deg, #fff, #f4ede0);
    box-shadow: inset 0 1px 0 #fff;
    cursor: pointer;
    font-size: calc(16 * var(--ui-px));
    line-height: 1;
    touch-action: manipulation;
    transition: transform 100ms ease, filter 100ms ease;
}

.hud-icon-btn:hover {
    transform: translateY(-1px);
    filter: brightness(1.02);
}

.hud-icon-btn:active {
    transform: scale(0.96);
}

.hud-icon-btn:disabled {
    opacity: 0.45;
    cursor: default;
}

.hud-menu-btn {
    font-size: calc(20 * var(--ui-px));
    font-weight: 800;
    color: var(--muted);
    padding-bottom: calc(7 * var(--ui-px));
}

.hud-badge {
    position: absolute;
    top: calc(-4 * var(--ui-px));
    right: calc(-4 * var(--ui-px));
    min-width: calc(15 * var(--ui-px));
    height: calc(15 * var(--ui-px));
    padding: 0 calc(3 * var(--ui-px));
    display: grid;
    place-items: center;
    border: 1.5px solid var(--panel);
    border-radius: 99px;
    background: var(--good);
    color: #fff;
    font-size: calc(9 * var(--ui-px));
    font-weight: 800;
}

.hud-badge[hidden] {
    display: none;
}

/* Corner stat cluster: a row of glossy capsules pinned to the screen's top-left
   (concept sheet style), floating over the scene. Score leads, Moves follows —
   each a small label over a hero number. Fixed to the viewport so it clears the
   board column on wide screens. */
.corner-stats {
    position: fixed;
    top: max(calc(10 * var(--ui-px)), env(safe-area-inset-top));
    left: max(calc(12 * var(--ui-px)), env(safe-area-inset-left));
    z-index: 2;
    /* Stacked, at a fixed reference-pixel width, so the cluster's footprint in
       the top-left corner is the same at every viewport size and aspect ratio
       — the centred .hud strip is sized to clear it (and .goals on the other
       side) rather than each panel reacting to the viewport on its own. */
    width: calc(124 * var(--ui-px));
    display: flex;
    flex-direction: column;
    align-items: stretch;
    gap: calc(8 * var(--ui-px));
}

.stat-pill {
    display: flex;
    align-items: center;
    gap: calc(8 * var(--ui-px));
    padding: calc(5 * var(--ui-px)) calc(16 * var(--ui-px)) calc(6 * var(--ui-px)) calc(10 * var(--ui-px));
    border-radius: 999px;
    border: 1px solid #eccf8f;
    background:
        radial-gradient(120% 140% at 0% 0%, rgb(232 161 60 / 18%), transparent 60%),
        linear-gradient(180deg, #fff8e8, #fbe9c2);
    box-shadow: 0 8px 20px rgb(39 35 28 / 22%), inset 0 1px 0 rgb(255 255 255 / 80%);
}

/* Moves reads as the quieter, cooler-neutral capsule so Score stays the hero. */
.stat-pill--moves {
    border-color: #e4d8c2;
    background:
        radial-gradient(120% 140% at 0% 0%, rgb(120 131 141 / 12%), transparent 60%),
        linear-gradient(180deg, #fffaf0, #f1ece0);
}

.stat-pill-icon {
    font-size: calc(23 * var(--ui-px));
    line-height: 1;
    filter: drop-shadow(0 2px 2px rgb(122 77 18 / 35%));
}

.stat-pill-body {
    display: flex;
    flex-direction: column;
    gap: 0;
    line-height: 1.05;
}

.stat-pill-label {
    color: #b0824a;
    font-size: calc(8 * var(--ui-px));
    font-weight: 800;
    letter-spacing: 0.09em;
    text-transform: uppercase;
}

/* Seven quick taps here open the dev overlay (installDevOverlayGesture); without
   this a phone reads the burst as double-tap zoom. */
.stat-pill--moves {
    touch-action: manipulation;
    user-select: none;
    -webkit-user-select: none;
}

.stat-pill--moves .stat-pill-label {
    color: var(--muted);
}

.stat-pill strong {
    color: #7a4d12;
    font-size: calc(22 * var(--ui-px));
    font-weight: 900;
    line-height: 1;
    font-variant-numeric: tabular-nums;
}

.stat-pill--moves strong {
    color: #3f4650;
}

.hud-best {
    color: #b0824a;
    font-size: calc(8 * var(--ui-px));
    font-weight: 800;
    letter-spacing: 0.04em;
    font-variant-numeric: tabular-nums;
}

/* Objectives as the concept's "Collect" panel: floating in the right margin
   beside the game shell, led by the level title and a "Collect:" label, with
   each goal's icon (in-game art or emoji) over a bold count below — no bars,
   the numbers carry the progress. Always on screen rather than gated behind a
   width breakpoint: the shell is capped at 620 * var(--ui-px) (see .game-shell)
   and Expand-mode scaling means there's essentially always room beside it at
   the 16:9-and-similar sizes this game targets. */
.goals {
    position: fixed;
    z-index: 2;
    top: max(calc(10 * var(--ui-px)), env(safe-area-inset-top));
    right: max(calc(12 * var(--ui-px)), env(safe-area-inset-right));
    /* A fixed reference-pixel width, not one derived from the leftover margin
       beside the shell: that margin shrinks as the aspect ratio squares up
       until the panel collapses narrower than its own contents and the level
       title spills off the right edge of the screen. Wide enough for the
       heading plus two goal columns (see .goal's min-width). */
    width: calc(160 * var(--ui-px));
    display: flex;
    flex-direction: row;
    flex-wrap: wrap;
    justify-content: center;
    gap: calc(4 * var(--ui-px)) calc(10 * var(--ui-px));
    padding: calc(12 * var(--ui-px));
    border-radius: calc(16 * var(--ui-px));
    border: 1px solid #ece0c8;
    background: linear-gradient(160deg, #faf6ee, #f1ece0);
    box-shadow: inset 0 1px 0 rgb(255 255 255 / 80%), 0 8px 20px rgb(39 35 28 / 14%);
}

.goals-heading {
    display: flex;
    flex: 1 0 100%;
    flex-direction: column;
    align-items: center;
    gap: calc(2 * var(--ui-px));
    margin-bottom: calc(4 * var(--ui-px));
}

.goals-level {
    max-width: 100%;
    color: var(--ink);
    font-size: calc(14 * var(--ui-px));
    font-weight: 900;
    line-height: 1.12;
    text-align: center;
}

.goals-collect {
    color: #8a5a2e;
    font-size: calc(12 * var(--ui-px));
    font-weight: 900;
    letter-spacing: 0.04em;
}

/* Items sit straight on the panel's cream, concept style — no per-card faces.
   Positioned so the landing shockwave (.goal-card-ring) can anchor to the card. */
.goal {
    position: relative;
    flex: 0 1 auto;
    min-width: calc(58 * var(--ui-px));
    padding: calc(2 * var(--ui-px)) calc(4 * var(--ui-px));
    display: flex;
    flex-direction: column;
    align-items: center;
    justify-content: center;
    gap: calc(3 * var(--ui-px));
}

.goal[hidden] {
    display: none;
}

.goal-icon {
    width: calc(34 * var(--ui-px));
    height: calc(34 * var(--ui-px));
    display: grid;
    place-items: center;
    font-size: calc(26 * var(--ui-px));
    line-height: 1;
}

/* Gem/geode/barricade icons reuse the board's tile art, which ships positioned
   (and oversized) for a board cell — pin it back into the icon box. */
.goal-icon .tile-shape,
.goal-icon .tile-art {
    position: static;
    transform: none;
    width: 100%;
    height: 100%;
}

.goal strong {
    font-size: calc(12 * var(--ui-px));
    font-weight: 900;
    color: #8a6a3a;
    font-variant-numeric: tabular-nums;
}

.goal.is-complete strong {
    color: var(--good);
}

.restoration-top {
    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: 12px;
    color: var(--muted);
    font-size: 0.82rem;
    font-weight: 800;
    text-transform: uppercase;
}

.world-scene {
    position: relative;
    width: 100%;
    height: 100%;
    min-height: 0;
    overflow: hidden;
}

.scene-stage {
    position: relative;
    overflow: hidden;
    background: #241f1c;
}

/* Scene art: full-frame landscape paintings, cover-cropped to the stage so any
   viewport (portrait phone or wide desktop) stays filled — the sides crop first,
   keeping the room's centre in view. */
.scene-img {
    position: absolute;
    inset: 0;
    width: 100%;
    height: 100%;
    display: block;
    object-fit: cover;
    object-position: center;
    pointer-events: none;
    user-select: none;
}

/* The incoming stage image rides above the old one and simply fades up from
   transparent to fully opaque, so the restoration cross-dissolves in place.
   main.js swaps the base image underneath once the fade lands. */
.scene-img-next {
    z-index: 1;
    opacity: 0;
    animation: scene-fade var(--fade-duration, 1000ms) ease forwards;
}

@keyframes scene-fade {
    from {
        opacity: 0;
    }

    to {
        opacity: 1;
    }
}

/* Reduced motion: no cross-dissolve — the next image simply takes over. */
@media (prefers-reduced-motion: reduce) {
    .scene-img-next {
        animation-duration: 1ms;
    }
}

/* The scene dialogue: the line that names the next job, spoken over the ruined
   scene between the win and the Restore button. Sits at the foot of the screen
   like a visual-novel card — speaker chip riding the top edge, the line in the
   body, a chevron once it is fully typed. The layer takes the press anywhere.

   Every size below is in reference pixels (see --ui-px): the card, the type and
   the speaker are one piece of UI and scale together, so a short landscape window
   shrinks the whole thing instead of leaving a fixed-size card to swallow it. */
.scene-dialogue-layer {
    position: fixed;
    inset: 0;
    z-index: 4;
    display: grid;
    align-items: end;
    padding:
        0
        max(calc(12 * var(--ui-px)), env(safe-area-inset-left))
        max(calc(18 * var(--ui-px)), env(safe-area-inset-bottom));
    cursor: pointer;
}

.scene-dialogue-layer.is-hidden {
    display: none;
}

/* Card and speaker share one centred stage, so on a wide screen she stays beside
   her line instead of drifting off to the viewport edge. */
.scene-dialogue-stage {
    position: relative;
    width: min(100%, calc(620 * var(--ui-px)));
    margin: 0 auto;
}

/* The speaker stands at the card's left with her feet tucked behind its top edge
   — she is earlier in the DOM than the card, so the card paints over them.

   --portrait-drop sits her lower against the card. It is paired with a matching
   bottom clip: the drop alone would push her feet past the card's bottom edge and
   leave them poking out below it, so the same distance is cut off the bottom of
   the image first. Clipping happens in her own box, before the transform moves
   it, so the two cancel — her visible bottom edge stays on the original feet line
   (78 * --ui-px into the card, safely behind it) while the figure itself sits
   lower. Change --portrait-drop alone to retune; the clip follows it. */
.scene-dialogue-portrait {
    --portrait-drop: calc(52 * var(--ui-px));
    position: absolute;
    left: calc(-28 * var(--ui-px));
    bottom: calc(100% - 78 * var(--ui-px));
    height: calc(380 * var(--ui-px));
    width: auto;
    pointer-events: none;
    user-select: none;
    filter: drop-shadow(0 14px 26px rgb(12 10 14 / 45%));
    clip-path: inset(0 0 var(--portrait-drop) 0);
    opacity: 0;
    transform: translateY(calc(var(--portrait-drop) + 18px));
    transition: opacity 300ms ease, transform 300ms cubic-bezier(0.2, 0.86, 0.28, 1);
}

.scene-dialogue-portrait[hidden] {
    display: none;
}

.scene-dialogue-layer.is-showing .scene-dialogue-portrait {
    opacity: 1;
    transform: translateY(var(--portrait-drop));
}

.scene-dialogue {
    position: relative;
    padding: calc(26 * var(--ui-px)) calc(22 * var(--ui-px)) calc(20 * var(--ui-px));
    border-radius: calc(18 * var(--ui-px));
    background: linear-gradient(180deg, #fffdf6, #fdf3e0);
    box-shadow:
        0 22px 50px rgb(18 14 10 / 40%),
        inset 0 1px 0 rgb(255 255 255 / 90%);
    transform: translateY(14px);
    opacity: 0;
    transition: opacity 260ms ease, transform 260ms cubic-bezier(0.2, 0.86, 0.28, 1);
}

.scene-dialogue-layer.is-showing .scene-dialogue {
    opacity: 1;
    transform: translateY(0);
}

/* The speaker chip rides the card's top edge, the way a nameplate does. */
.scene-dialogue-name {
    position: absolute;
    top: calc(-14 * var(--ui-px));
    left: calc(20 * var(--ui-px));
    padding: calc(5 * var(--ui-px)) calc(16 * var(--ui-px));
    border-radius: 999px;
    color: #2a1c10;
    font-size: calc(13 * var(--ui-px));
    font-weight: 800;
    letter-spacing: 0.04em;
    background: linear-gradient(180deg, var(--accent-soft), var(--accent));
    box-shadow:
        0 6px 14px rgb(88 56 12 / 32%),
        inset 0 1px 0 rgb(255 255 255 / 70%);
}

.scene-dialogue-name:empty {
    display: none;
}

.scene-dialogue-text {
    margin: 0;
    min-height: 2.6em;
    padding-right: calc(28 * var(--ui-px));
    color: #4a2f1c;
    font-size: calc(20 * var(--ui-px));
    font-weight: 700;
    line-height: 1.4;
    text-wrap: pretty;
}

/* The part of the line not yet typed: laid out, so the card is its final size
   from the first character, but unread — the reader sees only the ink that has
   landed (see renderDialogueText). */
.scene-dialogue-rest {
    visibility: hidden;
}

/* The typed text so far, with the blinking caret riding its trailing edge as a
   border rather than a separate glyph. It has to be a border and not an
   inline-block caret span: an inline-block sitting between the typed text and
   the rest of the line is a legal line-break point even with no whitespace
   there, so for one frame the browser could stray the just-typed letter alone
   onto the end of a line, then snap the whole word down to the next line the
   moment its neighbour landed beside it instead of across that inline-block. A
   border is part of this span's own box, not a separate one, so it carries none
   of that ambiguity — the line only ever breaks where the untyped line would
   have broken anyway. Only rides along while characters are still landing; the
   chevron below takes over the moment the line is complete (see
   .scene-dialogue-layer.is-read .scene-dialogue-next). */
.scene-dialogue-typed {
    padding-right: 2px;
    border-right: 2px solid var(--accent);
    animation: scene-dialogue-caret 900ms steps(2, start) infinite;
}

@keyframes scene-dialogue-caret {
    50% {
        border-right-color: transparent;
    }
}

/* The "go on" chevron only turns up once the whole line has landed. */
.scene-dialogue-next {
    position: absolute;
    right: calc(20 * var(--ui-px));
    bottom: calc(16 * var(--ui-px));
    width: calc(18 * var(--ui-px));
    height: calc(12 * var(--ui-px));
    opacity: 0;
    background: linear-gradient(180deg, var(--accent-soft), var(--accent));
    clip-path: polygon(0 0, 100% 0, 50% 100%);
    transition: opacity 200ms ease;
}

.scene-dialogue-layer.is-read .scene-dialogue-next {
    opacity: 1;
    animation: scene-dialogue-next 1200ms ease-in-out infinite;
}

/* The line that ends the level hands over by this button instead of the chevron
   (see showSceneDialogue), so the press that leaves the room — and may bring an
   ad with it — is something the player aims at rather than the same tap that has
   been turning pages. Docked where the chevron sits, and like the chevron it
   only arrives once the line has finished typing. */
.scene-dialogue-layer.is-gated .scene-dialogue-next {
    display: none;
}

.scene-dialogue-advance {
    /* Out of the flow entirely on the lines that do not end a level, so an
       ordinary card keeps exactly the height it had before this button existed.
       The gated card below puts it back and grows to fit. */
    display: none;
    width: 100%;
    margin-top: calc(14 * var(--ui-px));
    /* A full-width bar under the line, not a pill in a corner: this is a
       fingertip target that ends the level (and may bring an ad with it), so it
       gets the whole card's width and a floor of 48px whatever --ui-px works out
       to on a given screen — the corner pill measured 38.7px tall on a 375px
       phone, under both Apple's 44pt and Material's 48dp. */
    min-height: max(calc(46 * var(--ui-px)), 48px);
    padding: 0 calc(18 * var(--ui-px));
    border: 1px solid var(--accent);
    border-radius: calc(12 * var(--ui-px));
    /* Ink on the gold, not white: the gradient starts at --accent-soft, which
       white text would all but vanish into. */
    color: var(--ink);
    background: linear-gradient(180deg, var(--accent-soft), var(--accent));
    box-shadow: 0 calc(4 * var(--ui-px)) calc(10 * var(--ui-px)) rgb(36 31 25 / 22%);
    opacity: 0;
    pointer-events: none;
    cursor: pointer;
    font-size: calc(15 * var(--ui-px));
    font-weight: 800;
    touch-action: manipulation;
    transition: opacity 200ms ease, transform 100ms ease;
}

/* Laid out from the moment the gated line appears, but not yet visible: the card
   is sized by its content, so reserving the bar now keeps it from growing under
   the reader when the line lands — the same reason the untyped tail of the text
   is kept in the DOM (see renderDialogueText). */
.scene-dialogue-layer.is-gated .scene-dialogue-advance {
    display: block;
}

.scene-dialogue-layer.is-gated.is-read .scene-dialogue-advance {
    opacity: 1;
    pointer-events: auto;
}

.scene-dialogue-advance:active {
    transform: translateY(1px);
}

/* Desktop, where the pointer is a mouse: a small target is no trouble to hit, so
   the bar shrinks to a compact pill. It stays in the flow on a row of its own,
   right-aligned under the line: pinned absolutely in the corner it sat on top of
   the last line of text whenever that line ran to the card's right edge.
   Matched on input rather than width, like the --ui-scale switch at the top of
   this file. */
@media (hover: hover) and (pointer: fine) {
    .scene-dialogue-layer.is-gated .scene-dialogue-advance {
        width: auto;
        min-height: calc(32 * var(--ui-px));
        margin-top: calc(8 * var(--ui-px));
        margin-left: auto;
        border-radius: calc(10 * var(--ui-px));
        padding: 0 calc(16 * var(--ui-px));
        font-size: calc(13 * var(--ui-px));
    }
}

@keyframes scene-dialogue-next {
    0%,
    100% {
        transform: translateY(0);
    }

    50% {
        transform: translateY(4px);
    }
}

@media (prefers-reduced-motion: reduce) {
    .scene-dialogue,
    .scene-dialogue-portrait {
        transition: opacity 140ms ease;
        transform: none;
    }

    .scene-dialogue-layer.is-read .scene-dialogue-next {
        animation: none;
    }
}

/* The end-of-game card (see showEndingScreen in main.js): docked in the same
   spot as the dialogue card, over the fully restored scene backdrop, but
   static — nothing to tap, nowhere further to go, so it takes no pointer
   events of its own. */
.ending-screen {
    position: fixed;
    inset: 0;
    z-index: 4;
    display: grid;
    align-items: end;
    padding:
        0
        max(calc(12 * var(--ui-px)), env(safe-area-inset-left))
        max(calc(18 * var(--ui-px)), env(safe-area-inset-bottom));
    pointer-events: none;
}

.ending-screen.is-hidden {
    display: none;
}

.ending-screen-stage {
    width: min(100%, calc(620 * var(--ui-px)));
    margin: 0 auto;
}

.ending-card {
    position: relative;
    display: grid;
    gap: calc(8 * var(--ui-px));
    padding: calc(26 * var(--ui-px)) calc(22 * var(--ui-px)) calc(22 * var(--ui-px));
    border-radius: calc(18 * var(--ui-px));
    background: linear-gradient(180deg, #fffdf6, #fdf3e0);
    box-shadow:
        0 22px 50px rgb(18 14 10 / 40%),
        inset 0 1px 0 rgb(255 255 255 / 90%);
    opacity: 0;
    transform: translateY(14px);
    transition: opacity 420ms ease, transform 420ms cubic-bezier(0.2, 0.86, 0.28, 1);
}

.ending-screen.is-showing .ending-card {
    opacity: 1;
    transform: translateY(0);
}

.ending-kicker {
    justify-self: start;
    padding: calc(5 * var(--ui-px)) calc(16 * var(--ui-px));
    border-radius: 999px;
    color: #2a1c10;
    font-size: calc(13 * var(--ui-px));
    font-weight: 800;
    letter-spacing: 0.08em;
    text-transform: uppercase;
    background: linear-gradient(180deg, var(--accent-soft), var(--accent));
    box-shadow:
        0 6px 14px rgb(88 56 12 / 32%),
        inset 0 1px 0 rgb(255 255 255 / 70%);
}

.ending-title {
    color: #2a1c10;
    font-size: calc(24 * var(--ui-px));
    font-weight: 800;
}

.ending-text {
    margin: 0;
    color: #4a2f1c;
    font-size: calc(17 * var(--ui-px));
    font-weight: 600;
    line-height: 1.45;
    text-wrap: pretty;
}

/* Run stats under the finale line: the .collection-top / .collection-grid /
   .collection-card classes are the same ones the Keepsakes screen uses (see
   renderKeepsakeCards) — .ending-keepsakes-grid only shrinks the card size to
   fit a card instead of a full screen. */
.ending-stats {
    display: grid;
    gap: calc(10 * var(--ui-px));
    margin-top: calc(4 * var(--ui-px));
    padding-top: calc(14 * var(--ui-px));
    border-top: 1px solid var(--line);
}

.ending-keepsakes-grid {
    grid-template-columns: repeat(auto-fill, minmax(calc(76 * var(--ui-px)), 1fr));
    gap: calc(8 * var(--ui-px));
}

.ending-best {
    color: var(--muted);
    font-size: calc(13 * var(--ui-px));
    font-weight: 700;
}

/* Opens the same reset-progress confirmation the World hub's own button does
   (see openResetConfirm) — the HUD it normally lives in is hidden here, so the
   card grows its own way back to a fresh run. */
.ending-actions {
    display: flex;
    gap: calc(8 * var(--ui-px));
    margin-top: calc(4 * var(--ui-px));
}

.ending-actions .icon-button {
    height: calc(42 * var(--ui-px));
    pointer-events: auto;
}

.ending-review {
    flex: 1;
}

.ending-restart {
    flex: 1.4;
}

/* Lets the player tuck the card away to admire the finished room, since the
   HUD's own "Admire the view" toggle is hidden along with the rest of the
   chrome here (see toggleEndingMinimize in main.js). */
.ending-minimize {
    position: absolute;
    top: calc(14 * var(--ui-px));
    right: calc(14 * var(--ui-px));
    display: grid;
    place-items: center;
    width: calc(30 * var(--ui-px));
    height: calc(30 * var(--ui-px));
    border: none;
    border-radius: 50%;
    background: rgb(32 32 38 / 8%);
    color: #6b4a28;
    font-size: calc(16 * var(--ui-px));
    line-height: 1;
    cursor: pointer;
    pointer-events: auto;
}

.ending-minimize:hover {
    background: rgb(32 32 38 / 14%);
}

/* Tucked away: the card leaves the same way the dialogue card arrived, and this
   one small tab is all that is left on screen — tap it to bring the card back. */
.ending-screen.is-minimized .ending-card {
    opacity: 0;
    transform: translateY(14px);
    pointer-events: none;
}

.ending-reopen {
    position: fixed;
    left: 50%;
    bottom: max(calc(18 * var(--ui-px)), env(safe-area-inset-bottom));
    transform: translateX(-50%);
    display: grid;
    place-items: center;
    width: calc(52 * var(--ui-px));
    height: calc(52 * var(--ui-px));
    border: none;
    border-radius: 50%;
    background: linear-gradient(180deg, var(--accent-soft), var(--accent));
    box-shadow:
        0 10px 24px rgb(18 14 10 / 40%),
        inset 0 1px 0 rgb(255 255 255 / 70%);
    font-size: calc(22 * var(--ui-px));
    line-height: 1;
    cursor: pointer;
    pointer-events: auto;
}

.ending-reopen[hidden] {
    display: none;
}

@media (prefers-reduced-motion: reduce) {
    .ending-card,
    .ending-screen.is-minimized .ending-card {
        transition: opacity 140ms ease;
        transform: none;
    }
}

/* The keepsake showcase: what the level turned up, held in the middle of the
   screen while the forewoman's first line talks about it. It shares the dialogue
   card's layer height but never its taps — the press that reads the line has to
   fall through to the card underneath. */
.keepsake-showcase-layer {
    position: fixed;
    inset: 0;
    z-index: 3;
    display: grid;
    place-items: center;
    pointer-events: none;
    /* Pulls focus off the room behind the find without hiding it. */
    background: radial-gradient(circle at 50% 42%, rgb(24 17 10 / 52%) 0 24%, rgb(24 17 10 / 22%) 46%, transparent 68%);
    opacity: 0;
    transition: opacity 320ms ease;
}

.keepsake-showcase-layer.is-hidden {
    display: none;
}

.keepsake-showcase-layer.is-showing {
    opacity: 1;
}

/* Sits above the true centre: the dialogue card owns the bottom of the screen,
   so this centres in what is left rather than tucking behind it. */
.keepsake-showcase {
    display: flex;
    justify-content: center;
    align-items: flex-start;
    gap: calc(18 * var(--ui-px));
    transform: translateY(calc(-70 * var(--ui-px)));
}

.keepsake-showcase-item {
    position: relative;
    display: grid;
    justify-items: center;
    gap: calc(12 * var(--ui-px));
    animation: keepsake-showcase-in 620ms cubic-bezier(0.16, 1.1, 0.3, 1) both;
    animation-delay: var(--keepsake-delay, 0ms);
}

/* Frame and glow share one box the size of the frame, so the glow is centred on
   the keepsake by construction — no percentage offsets to keep in step with the
   frame's size, and nothing for the pulse animation's transform to overwrite. */
.keepsake-showcase-stage {
    position: relative;
    width: calc(188 * var(--ui-px));
    height: calc(188 * var(--ui-px));
}

/* The glow that breathes out from behind the frame. It sits under the frame by
   paint order — both are positioned and this one comes first in the DOM — rather
   than by a negative z-index, which would drop it behind the layer's vignette. */
.keepsake-showcase-halo {
    position: absolute;
    inset: calc(-56 * var(--ui-px));
    border-radius: 50%;
    background: radial-gradient(circle, color-mix(in srgb, var(--keepsake-color, #f3cf6a) 60%, transparent) 0 34%, transparent 70%);
    animation: keepsake-showcase-halo 2600ms ease-in-out infinite;
}

/* The find itself: no card behind it — the art carries its own edges, so it is
   held on the halo alone, with a slow drift so it reads as held rather than
   pasted on. */
.keepsake-showcase-frame {
    position: relative;
    display: grid;
    place-items: center;
    width: 100%;
    height: 100%;
    animation: keepsake-showcase-float 4200ms ease-in-out 620ms infinite;
}

/* Full box now that nothing frames it, and a deeper shadow so it still lifts off
   the scene behind. */
.keepsake-showcase-frame .artifact-art-showcase {
    position: static;
    transform: none;
    width: 100%;
    height: 100%;
    filter: drop-shadow(0 calc(10 * var(--ui-px)) calc(18 * var(--ui-px)) rgb(18 14 10 / 55%));
}

/* Keepsakes with no painted art yet still get the moment — their emoji simply
   fills the same box. */
.keepsake-showcase-glyph {
    font-size: calc(126 * var(--ui-px));
    line-height: 1;
    filter: drop-shadow(0 calc(10 * var(--ui-px)) calc(18 * var(--ui-px)) rgb(18 14 10 / 55%));
}

.keepsake-showcase-name {
    padding: calc(6 * var(--ui-px)) calc(18 * var(--ui-px));
    border-radius: 999px;
    background: linear-gradient(180deg, var(--accent-soft), var(--accent));
    color: #2a1c10;
    font-size: calc(15 * var(--ui-px));
    font-weight: 800;
    letter-spacing: 0.03em;
    box-shadow: 0 calc(6 * var(--ui-px)) calc(16 * var(--ui-px)) rgb(18 14 10 / 40%);
}

@keyframes keepsake-showcase-in {
    0% {
        opacity: 0;
        transform: translateY(calc(26 * var(--ui-px))) scale(0.6) rotate(-9deg);
    }

    62% {
        opacity: 1;
        transform: translateY(calc(-6 * var(--ui-px))) scale(1.06) rotate(2deg);
    }

    100% {
        opacity: 1;
        transform: translateY(0) scale(1) rotate(0deg);
    }
}

@keyframes keepsake-showcase-float {
    0%, 100% {
        transform: translateY(0) rotate(-0.8deg);
    }

    50% {
        transform: translateY(calc(-8 * var(--ui-px))) rotate(0.8deg);
    }
}

@keyframes keepsake-showcase-halo {
    0%, 100% {
        opacity: 0.55;
        transform: scale(1);
    }

    50% {
        opacity: 0.9;
        transform: scale(1.06);
    }
}

/* Reduced motion: the find still arrives, it just doesn't dance. */
@media (prefers-reduced-motion: reduce) {
    .keepsake-showcase-item {
        animation: none;
    }

    .keepsake-showcase-frame,
    .keepsake-showcase-halo {
        animation: none;
    }
}

/* The Restore tap: the player's hand in the restoration. After a level completes
   the ruined scene holds and this button sits on the part of the room about to
   change, waiting. The layer takes the tap anywhere (main.js positions the button
   itself in viewport pixels, so it tracks the cover-cropped art). */
.resto-tap-layer {
    position: fixed;
    inset: 0;
    z-index: 3;
    cursor: pointer;
}

.resto-tap-layer.is-hidden {
    display: none;
}

.resto-tap {
    position: fixed;
    left: 50%;
    top: 55%;
    display: grid;
    justify-items: center;
    gap: calc(8 * var(--ui-px));
    padding: 0;
    border: 0;
    background: none;
    color: #fdf6e7;
    cursor: pointer;
    transform: translate(-50%, -50%);
    animation: resto-tap-in 320ms cubic-bezier(0.2, 0.9, 0.3, 1.3) both;
}

/* The dial itself: a raised enamel disc with a lit rim, the premium HUD read. */
.resto-tap-glyph {
    position: relative;
    display: grid;
    place-items: center;
    width: calc(84 * var(--ui-px));
    height: calc(84 * var(--ui-px));
    border-radius: 50%;
    font-size: calc(32 * var(--ui-px));
    line-height: 1;
    background:
        radial-gradient(120% 120% at 30% 22%, rgb(255 255 255 / 46%), rgb(255 255 255 / 0%) 52%),
        linear-gradient(180deg, #7fc866, #48944a);
    box-shadow:
        0 0 0 5px rgb(255 250 240 / 92%),
        0 14px 30px rgb(18 26 16 / 42%),
        inset 0 -4px 10px rgb(24 54 24 / 42%),
        inset 0 3px 8px rgb(255 255 255 / 42%);
}

/* A halo that keeps pulsing out of the rim so the button reads as "press me"
   against busy art without moving the button itself. */
.resto-tap-glyph::after {
    content: "";
    position: absolute;
    inset: calc(-5 * var(--ui-px));
    border-radius: 50%;
    border: calc(3 * var(--ui-px)) solid rgb(255 250 240 / 76%);
    animation: resto-tap-halo 1800ms ease-out infinite;
    pointer-events: none;
}

.resto-tap-label {
    padding: calc(5 * var(--ui-px)) calc(14 * var(--ui-px));
    border-radius: 999px;
    font-size: calc(12 * var(--ui-px));
    font-weight: 800;
    letter-spacing: 0.1em;
    text-transform: uppercase;
    background: linear-gradient(180deg, rgb(54 44 62 / 92%), rgb(32 26 39 / 92%));
    box-shadow:
        0 8px 18px rgb(12 10 14 / 42%),
        inset 0 1px 0 rgb(255 255 255 / 18%);
}

.resto-tap:hover .resto-tap-glyph,
.resto-tap:focus-visible .resto-tap-glyph {
    background:
        radial-gradient(120% 120% at 30% 22%, rgb(255 255 255 / 56%), rgb(255 255 255 / 0%) 52%),
        linear-gradient(180deg, #8ad46f, #4fa353);
}

.resto-tap:active .resto-tap-glyph {
    transform: translateY(2px) scale(0.96);
}

.resto-tap:focus-visible {
    outline: none;
}

@keyframes resto-tap-in {
    from {
        opacity: 0;
        transform: translate(-50%, -50%) scale(0.6);
    }

    to {
        opacity: 1;
        transform: translate(-50%, -50%) scale(1);
    }
}

@keyframes resto-tap-halo {
    0% {
        opacity: 0.85;
        transform: scale(1);
    }

    70% {
        opacity: 0;
        transform: scale(1.5);
    }

    100% {
        opacity: 0;
        transform: scale(1.5);
    }
}

@media (prefers-reduced-motion: reduce) {
    .resto-tap {
        animation-duration: 1ms;
    }

    .resto-tap-glyph::after {
        animation: none;
    }
}

/* Scene-as-backdrop prototype */
.scene-backdrop {
    position: fixed;
    inset: 0;
    z-index: 0;
    display: none;
}

body.backdrop-on .scene-backdrop,
body.admire-on .scene-backdrop {
    display: block;
}

.scene-backdrop-scene {
    position: absolute;
    inset: 0;
}

.scene-backdrop-veil {
    position: absolute;
    inset: 0;
    pointer-events: none;
    /* Kept light: the concept look is a bright, cheerful room behind the board. */
    background: linear-gradient(180deg, rgb(20 18 24 / 4%) 0 38%, rgb(20 18 24 / 16%) 100%);
    transition: opacity 360ms ease;
}

body.is-revealing .scene-backdrop-veil,
body.admire-on .scene-backdrop-veil {
    opacity: 0;
}

body.backdrop-on .game-shell,
body.admire-on .game-shell {
    position: relative;
    z-index: 1;
    background: none;
}

body.backdrop-on {
    /* Concept-sheet board: a soft periwinkle panel floating over the room,
       light-rimmed, with the cell slots (see the ts-room tile face) on top.
       Held as custom properties because the panel is drawn twice over: as this
       element's own chrome, and — once a level's board can have squares missing
       — as the SVG path behind it (see .board-frame and renderBoardFrame). */
    --board-panel-top: rgb(88 103 165 / 93%);
    --board-panel-bottom: rgb(66 80 138 / 93%);
    --board-panel-rim: rgb(214 227 252 / 50%);
    --board-panel-lit: rgb(255 255 255 / 20%);
    --board-panel-shadow: 0 18px 40px rgb(12 10 14 / 34%);
}

body.backdrop-on .board {
    background: linear-gradient(180deg, var(--board-panel-top), var(--board-panel-bottom));
    border-color: var(--board-panel-rim);
    border-radius: calc(18 * var(--ui-px));
    box-shadow:
        var(--board-panel-shadow),
        inset 0 1px 0 var(--board-panel-lit);
}

/* The panel as a path, so it can follow a board that has squares missing. It
   carries the same fill, rim and shadow the .board chrome above does, and takes
   over from it the moment the frame renders (main.js sets .board-frame-on), so
   the board looks the same whether or not the level cuts a hole in it. */
.board-frame {
    position: absolute;
    inset: 0;
    width: 100%;
    height: 100%;
    filter: drop-shadow(var(--board-panel-shadow));
    pointer-events: none;
}

body.board-frame-on .board {
    /* The frame paints all of this now. The border stays, transparent: it is
       inside the box, so dropping it would resize the grid. */
    background: none;
    border-color: transparent;
    box-shadow: none;
}

.board-frame-fill { fill: url(#boardFrameFill); }
.board-frame-stop-top { stop-color: var(--board-panel-top); }
.board-frame-stop-bottom { stop-color: var(--board-panel-bottom); }

/* Stroked at double width and clipped to the path, so only the inner half
   shows — where a CSS border sits. */
.board-frame-rim {
    fill: none;
    stroke: var(--board-panel-rim);
    /* Double the 1px border it stands in for: the stroke straddles the outline
       and the clip keeps the inner half. Fixed px, like the border was. */
    stroke-width: 2px;
}

/* The `inset 0 1px 0` highlight: a light line along the top edge, fading out as
   the outline turns down the sides. */
.board-frame-lit {
    fill: none;
    stroke: url(#boardFrameLitFade);
    /* Wider than the rim and drawn under it, so a 1px line of it stays visible
       just inside the rim rather than being covered by it. */
    stroke-width: 4px;
}

.board-frame-lit-stop-top { stop-color: var(--board-panel-lit); }
.board-frame-lit-stop-bottom { stop-color: rgb(255 255 255 / 0%); }

/* A square the level's board does not have. It holds its place in the grid —
   tile lookups index the board's children by row * BOARD_SIZE + col — and
   nothing else: the panel behind it is shaped to leave the hole open. */
.tile-void {
    pointer-events: none;
}

/* Restoration reveal (auto, on level complete) and Admire mode (manual "View"
   toggle) both clear the board + HUD out of the way to show the full scene. */
.board-panel {
    transition: opacity 320ms ease, transform 320ms cubic-bezier(0.2, 0.86, 0.28, 1);
}

body.is-revealing .board-panel,
body.admire-on .board-panel,
body.is-ended .board-panel {
    opacity: 0;
    transform: translateY(8%) scale(0.92);
    pointer-events: none;
}

/* Landscape pins Undo/Hint (.status-row, inside .board-panel) to the viewport,
   and a transformed ancestor captures position: fixed — the buttons rode the
   board's slide in from the wrong place and snapped home when it ended. So
   there the panel only fades, and the slide moves down onto the board itself. */
@media (min-aspect-ratio: 1 / 1) {
    body.is-revealing .board-panel,
    body.admire-on .board-panel,
    body.is-ended .board-panel {
        transform: none;
    }

    .board-wrap {
        transition: transform 320ms cubic-bezier(0.2, 0.86, 0.28, 1);
    }

    body.is-revealing .board-wrap,
    body.admire-on .board-wrap,
    body.is-ended .board-wrap {
        transform: translateY(8%) scale(0.92);
    }
}

body.is-revealing .hud,
body.admire-on .hud,
body.is-ended .hud,
body.is-revealing .corner-stats,
body.admire-on .corner-stats,
body.is-ended .corner-stats {
    opacity: 0;
    transition: opacity 240ms ease;
    pointer-events: none;
}

/* Win hold: a short beat right after the winning move settles, before the board
   fades into the scene reveal (see WIN_HOLD_MS). A glow ring plus a quick scale
   pop marks the win so the player registers it before the board disappears. */
body.is-win-hold .board {
    animation: win-hold-glow 1000ms cubic-bezier(0.2, 0.8, 0.3, 1);
}

/* With the frame on, the ring is a filter on the path instead of a box-shadow on
   the board — a box-shadow ring would trace a rectangle around a panel that is
   not one. The board keeps the scale pop; the frame scales with it (it is the
   same size and origin), so the two stay locked together. */
body.board-frame-on.is-win-hold .board {
    animation-name: win-hold-pop;
}

body.board-frame-on.is-win-hold .board-frame {
    animation: win-hold-frame-glow 1000ms cubic-bezier(0.2, 0.8, 0.3, 1);
}

@keyframes win-hold-pop {
    0% {
        transform: scale(1);
    }

    25% {
        transform: scale(1.025);
    }

    60%,
    100% {
        transform: scale(1);
    }
}

@keyframes win-hold-frame-glow {
    0% {
        filter: drop-shadow(var(--board-panel-shadow)) drop-shadow(0 0 0 rgb(79 156 104 / 0%));
        transform: scale(1);
    }

    25% {
        filter: drop-shadow(var(--board-panel-shadow)) drop-shadow(0 0 calc(12 * var(--ui-px)) rgb(79 156 104 / 85%));
        transform: scale(1.025);
    }

    60% {
        transform: scale(1);
    }

    100% {
        filter: drop-shadow(var(--board-panel-shadow)) drop-shadow(0 0 calc(28 * var(--ui-px)) rgb(79 156 104 / 0%));
        transform: scale(1);
    }
}

@keyframes win-hold-glow {
    0% {
        box-shadow: var(--shadow), 0 0 0 0 rgb(79 156 104 / 80%);
        transform: scale(1);
    }

    25% {
        box-shadow: var(--shadow), 0 0 0 calc(10 * var(--ui-px)) rgb(79 156 104 / 65%);
        transform: scale(1.025);
    }

    60% {
        transform: scale(1);
    }

    100% {
        box-shadow: var(--shadow), 0 0 0 calc(28 * var(--ui-px)) rgb(79 156 104 / 0%);
        transform: scale(1);
    }
}

@media (prefers-reduced-motion: reduce) {
    body.is-win-hold .board,
    body.board-frame-on.is-win-hold .board-frame {
        animation-duration: 1ms;
    }
}

.board-panel {
    display: grid;
    grid-template-rows: minmax(0, 1fr) auto;
    justify-items: center;
    min-height: 0;
    gap: calc(5 * var(--ui-px));
}

.board-wrap {
    position: relative;
    /* --board-max is the board's own cap in reference pixels, separate from
       --ui-scale so the board can be reined in without shrinking the HUD and
       dialogue with it (see :root). The svh term is the short-window guard: it
       keeps the board clear of the HUD above and the Undo row below. */
    width: min(100%, calc(var(--board-max) * var(--ui-px)), calc(100svh - 188 * var(--ui-px)));
    aspect-ratio: 1;
    align-self: center;
}

/* ── LANDSCAPE: LEFT RAIL + BOARD ON THE RIGHT ───────────────────────────────
   The phone is held in two hands and swiped with the right thumb, which reaches
   the right edge of the screen and not its middle, so the board moves off centre
   and hugs the right edge.

   Everything else moves the other way rather than staying over the board: the
   whole chrome — nav, stat pills, goals, and Undo/Hint — becomes one rail down
   the left side. That is what buys the board the room: with nothing above it and
   nothing below it, its size stops being "whatever is left between the HUD and
   the Undo row" and becomes the full height of the screen, which in landscape is
   the only axis that was ever short. The scene art keeps the rest of the left
   half, which the centred board used to sit on top of.

   Portrait is untouched: there the shell already fills the width, there is
   nowhere to shift the board to and no free side to put a rail on. */
@media (min-aspect-ratio: 1 / 1) {
    :root {
        /* The rail is drawn a size up from the rest of the UI: out on the side
           it has the room, and at 1x its labels read small next to a board that
           fills the height. Applied by redefining --ui-px on the rail below. */
        --rail-scale: 1.2;
        /* The rail's footprint — wide enough for .goals, the widest thing in it
           — and the one number the board's width budget has to subtract. */
        --rail-width: calc(172 * var(--ui-px) * var(--rail-scale));
        /* Screen height is what actually limits a landscape board (see
           .board-wrap), so the reference-pixel cap is only here to stop it
           running away to the full height of a large desktop monitor. */
        --board-max: 720;
        /* How far the board stands off the right edge. Flush against it was
           further than the right thumb actually wants to reach, so it comes in
           a little — still well right of centre. */
        --board-inset: 6vw;
    }

    .game-shell {
        /* Full width, with the board right-aligned inside it, rather than a
           centred column that would clip the board at 620 reference pixels. */
        width: 100%;
        /* The rail is fixed to the viewport, so the shell is now just the board:
           one row, the whole height. */
        grid-template-rows: minmax(0, 1fr);
    }

    /* The HUD strip stops being a strip and becomes the rail: nav, then the stat
       pills, then the goals, stacked down the left edge by the grid + gap it
       already has. Its own cream backing goes — each panel inside it brings its
       own, and one card around all three read as a wall. */
    .hud {
        position: fixed;
        z-index: 2;
        top: max(calc(10 * var(--ui-px)), env(safe-area-inset-top));
        left: max(calc(12 * var(--ui-px)), env(safe-area-inset-left));
        width: var(--rail-width);
        justify-self: auto;
        padding: 0;
        border-radius: 0;
        background: none;
        box-shadow: none;
    }

    /* One reference pixel, --rail-scale times over, for everything in the rail
       and its foot (.status-row, placed further down). Redefined rather than
       multiplied in at each size, so every panel in there grows together.
       Same formula as :root — var() in a custom property resolves where it is
       declared, so it cannot be a multiple of the inherited --ui-px.
       The last term is a height budget. The rail is pinned to the top and its
       Undo/Hint foot to the bottom, independently, so nothing else stops them
       meeting: on a short phone a four-goal level ran the foot up over the
       goals. The tallest rail — four goals, a two-line title (clamped to two,
       see .goals-level below), the foot, both pins and a gap — is ~510 rail
       pixels, so it never asks for more than 100svh / 520 each. Pure CSS on
       purpose: a measured fit lands after the first paint and visibly jumps. */
    .hud,
    .status-row {
        --ui-px: min(
            calc(min(100vw / var(--ui-ref-width), 100svh / var(--ui-ref-height)) * var(--ui-scale) * var(--rail-scale)),
            calc(100svh / 520)
        );
    }

    /* Two lines at most, so the budget above holds for any level name. */
    .goals-level {
        display: -webkit-box;
        -webkit-box-orient: vertical;
        -webkit-line-clamp: 2;
        overflow: hidden;
    }

    /* Rows of the rail above, not panels pinned to the top corners. */
    .corner-stats,
    .goals {
        position: static;
        width: 100%;
    }

    .board-panel {
        /* No status row under the board any more — it moved into the rail. */
        grid-template-rows: minmax(0, 1fr);
        justify-items: end;
    }

    .board-wrap {
        /* Height first: the board is as tall as the screen lets it be, less the
           shell's own padding. The middle term is the rail's column, kept clear
           so the two can never meet at a squarer aspect ratio (where the board
           would otherwise be wide enough to run under it). */
        width: min(
            calc(100svh - 24 * var(--ui-px)),
            calc(100vw - var(--rail-width) - var(--board-inset) - 40 * var(--ui-px)),
            calc(var(--board-max) * var(--ui-px))
        );
        margin-right: var(--board-inset);
    }

    /* Desktop: played with a mouse, so there is no thumb to reach for and the
       board goes back to the middle. Centred, it has the rail's width to keep
       clear on both sides, not just the left. */
    @media (hover: hover) and (pointer: fine) {
        .board-panel {
            justify-items: center;
        }

        .board-wrap {
            width: min(
                calc(100svh - 24 * var(--ui-px)),
                calc(100vw - 2 * var(--rail-width) - 64 * var(--ui-px)),
                calc(var(--board-max) * var(--ui-px))
            );
            margin-right: 0;
        }
    }

    /* The rail's foot — the status line with Undo and Hint under it — is set
       with the rest of .status-row further down the sheet, where it can win on
       source order. */
}

.board {
    width: 100%;
    height: 100%;
    display: grid;
    /* Track count comes from the level (setBoardSize in main.js writes it), so a
       7x7 level lays itself out here with no other change: the cells simply take
       a smaller share of the same board. */
    grid-template-columns: repeat(var(--board-cells, 6), minmax(0, 1fr));
    grid-template-rows: repeat(var(--board-cells, 6), minmax(0, 1fr));
    /* Tight gaps + slim padding: bigger cells (and items) in the same footprint,
       matching the concept sheet's near-touching items. Reference pixels like the
       rest of the HUD (see --ui-px), so the board's own chrome scales with it
       instead of the cells growing while the frame around them stays fixed. */
    --board-gap: calc(5 * var(--ui-px));
    gap: var(--board-gap);
    padding: calc(9 * var(--ui-px));
    border: 1px solid var(--line);
    border-radius: calc(8 * var(--ui-px));
    background: #e7dfd1;
    box-shadow: var(--shadow);
    overflow: hidden;
}

/* There is deliberately no `.board.is-busy` rule here.
 *
 * `is-busy` goes on and off twice a move, and the cells' styling must not hang
 * off it. It used to: `.board.is-busy .tile { pointer-events: none }`. In a
 * trace of ordinary play the swipe that starts a move spent 11.4ms of its
 * 16.2ms in forced style recalc, and this rule was almost all of it — toggling
 * the class measured 1.3ms on desktop (call it ten on the phone) against 0ms
 * with the rule gone.
 *
 * The cost is not selector matching. `pointer-events` is an INHERITED property,
 * so changing it on the board pushes a recalc through all 34 cells and their
 * subtrees. Measured: the same rule setting a non-inherited property is free,
 * and so is a class nothing matches. Moving it onto the board rather than the
 * cells does not help — inheritance carries it down either way — and neither
 * does `.board:not(.is-busy) .tile:hover`, which merely makes every cell's own
 * style depend on the board's class again (1.2ms).
 *
 * Nothing guards less because of this. Input is refused in JS by
 * isBoardInteractionLocked, checked by both entry points (playCell,
 * beginTileSwipe), and it covers a strictly wider condition than `is-busy`:
 * ads, game over and the completion beat as well. What is genuinely lost is
 * cosmetic and desktop-only — a cell still lights up under the cursor while the
 * board animates, though clicking it does nothing. There is no hover on the
 * phones this ships to. */


/* Cells hold a compositor layer of their own while the board is live (see
 * setBoardLive in main.js: on from the first beat of a move, released a couple
 * of seconds after everything settles).
 *
 * Without it a cell is promoted when one of its animations starts and dropped
 * when it ends, and a single move is four or five such beats — swap, destroy,
 * fall, appear, then the next cascade round. Each promotion allocates a texture
 * on the GPU, rasterises the cell into it and uploads it; at dpr 3 a cell is a
 * 366x366 texture. A device trace of 11 seconds of play counted 137 layers
 * created and thrown away against 6 that lived, and the count of new layers per
 * second tracked GPU busy time at r = 0.91. Holding the layer across the whole
 * move turns per-beat allocation into one per burst of play.
 *
 * It is scoped to a class, not left on permanently, because the layers cost
 * memory for as long as they exist — around 0.5 MB per cell at dpr 3, against
 * the ~60 MB the process was already holding. Idle boards (menus, the world
 * map, a level brief) give it back.
 *
 * They cost main-thread time too: Commit walks every layer, so pinning 34 of
 * them raises Commit p90 from 0.41ms to around 0.7ms. Measured against what it
 * buys, that is a good trade — taking the class away doubled GPU busy time,
 * 19.9% to 38.6%. See PIN_LAYERS in main.js for the paired runs, and
 * ?pinlayers=0 to repeat them. */
.board.is-live .tile {
    will-change: transform;
}

/* Dead-board rescue: the loose tiles were dealt out again because nothing could
   be swapped (see rescueDeadBoard). Everything on the board changes place at once
   with no move behind it, so it needs a beat that reads as "these resettled" —
   a quick drop-and-settle on every tile, over the status line that says why. */
.board.is-reshuffled .tile {
    animation: tile-reshuffle 420ms cubic-bezier(0.2, 0.9, 0.3, 1) both;
}

@keyframes tile-reshuffle {
    0% {
        transform: translateY(calc(-4 * var(--ui-px))) scale(0.9);
        opacity: 0.25;
    }

    60% {
        transform: translateY(0) scale(1.03);
        opacity: 1;
    }

    100% {
        transform: translateY(0) scale(1);
        opacity: 1;
    }
}

@media (prefers-reduced-motion: reduce) {
    .board.is-reshuffled .tile {
        animation: none;
    }
}

.score-popups {
    position: absolute;
    inset: 0;
    z-index: 3;
    pointer-events: none;
}

.score-popup {
    position: absolute;
    left: 50%;
    top: 50%;
    min-width: 76px;
    padding: 7px 12px;
    border: 2px solid rgb(32 32 38 / 10%);
    border-radius: 999px;
    color: #202026;
    background: rgb(255 250 240 / 94%);
    box-shadow: 0 10px 24px rgb(36 31 25 / 16%);
    font-size: 1.2rem;
    font-weight: 950;
    line-height: 1;
    letter-spacing: 0;
    text-align: center;
    white-space: nowrap;
    transform: translate(-50%, -50%);
    animation: score-popup var(--score-popup-duration, 860ms) cubic-bezier(0.16, 1, 0.3, 1) forwards;
}

.score-popup-bomb {
    color: #fffaf0;
    border-color: rgb(32 32 38 / 24%);
    background: #33313c;
}

.score-popup-combo {
    min-width: 116px;
    color: #33313c;
    border-color: rgb(241 199 91 / 54%);
    background: #f1c75b;
    font-size: 1.45rem;
    box-shadow:
        0 0 0 4px rgb(241 199 91 / 22%),
        0 14px 30px rgb(36 31 25 / 20%);
}

/*
 * Cascade combo popup: a stacked "Chain ×N!" badge over the (boosted) amount.
 * --chain-heat (0..5) tracks how deep the chain is and pushes the glow warmer
 * so a long cascade reads visibly hotter than a two-step one.
 */
.score-popup-chained {
    display: flex;
    flex-direction: column;
    align-items: center;
    gap: 3px;
    min-width: 96px;
    padding: 6px 14px;
    color: #4a2f05;
    border-color: rgb(241 181 74 / 70%);
    background: linear-gradient(180deg, #fff4d8 0%, #f6d98f 100%);
    box-shadow:
        0 0 0 calc(3px + var(--chain-heat, 0) * 1px) rgb(241 176 74 / calc(20% + var(--chain-heat, 0) * 6%)),
        0 14px 30px rgb(48 33 10 / 24%);
}

.score-popup-chain {
    font-size: 0.72rem;
    font-weight: 950;
    letter-spacing: 0.08em;
    text-transform: uppercase;
    color: #b26a09;
    text-shadow: 0 1px 0 rgb(255 255 255 / 55%);
}

.score-popup-amount {
    font-size: 1.32rem;
    font-weight: 950;
    line-height: 1;
}

/*
 * Collected-artifact burst: a glowing copy of the find pops with a sparkle
 * shower and twin shockwave rings, then floats up off the board as a reward.
 * The container is anchored at the cell centre; the coin, rings and sparkles
 * each animate on their own timeline.
 */
.artifact-collect-burst {
    position: absolute;
    left: 50%;
    top: 50%;
    z-index: 4;
    width: clamp(2.4rem, 13vw, 3.8rem);
    aspect-ratio: 1;
    transform: translate(-50%, -50%);
    pointer-events: none;
}

/* Twin shockwave rings pulsing outward from the collection point. */
.artifact-collect-burst::before,
.artifact-collect-burst::after {
    content: "";
    position: absolute;
    inset: 0;
    border-radius: 50%;
    border: 2px solid var(--collect-color, #f3cf6a);
    opacity: 0;
    animation: artifact-collect-ring var(--collect-duration, 950ms) cubic-bezier(0.16, 0.8, 0.27, 1) forwards;
    animation-delay: var(--collect-stagger, 0ms);
}

.artifact-collect-burst::after {
    border-width: 3px;
    animation-delay: calc(var(--collect-stagger, 0ms) + var(--collect-duration, 950ms) * 0.16);
}

.artifact-collect-coin {
    position: absolute;
    inset: 0;
    display: grid;
    place-items: center;
    border-radius: 50%;
    font-size: clamp(1.15rem, 7.5vw, 2.2rem);
    line-height: 1;
    will-change: transform, opacity;
    background:
        radial-gradient(circle at 50% 42%, rgb(255 255 255 / 92%) 0 18%, transparent 32%),
        radial-gradient(circle at 50% 50%, color-mix(in srgb, var(--collect-color, #f3cf6a) 82%, #ffffff) 0 46%, transparent 72%);
    filter:
        drop-shadow(0 0 10px var(--collect-color, #f3cf6a))
        drop-shadow(0 0 22px color-mix(in srgb, var(--collect-color, #f3cf6a) 55%, transparent));
    animation: artifact-collect-coin var(--collect-duration, 950ms) cubic-bezier(0.22, 1.12, 0.3, 1) both;
    animation-delay: var(--collect-stagger, 0ms);
}

/* Halo ring that pulses off the coin as it arrives at the centre. */
.artifact-collect-coin::after {
    content: "";
    position: absolute;
    inset: -20%;
    border-radius: 50%;
    border: 3px solid var(--collect-color, #f3cf6a);
    opacity: 0;
    animation: artifact-collect-halo calc(var(--collect-duration, 950ms) * 0.4) ease-out forwards;
    animation-delay: calc(var(--collect-stagger, 0ms) + var(--collect-duration, 950ms) * 0.4);
}

.artifact-collect-sparkle {
    position: absolute;
    left: 50%;
    top: 50%;
    width: 16%;
    aspect-ratio: 1;
    margin: -8% 0 0 -8%;
    border-radius: 50%;
    opacity: 0;
    background: radial-gradient(circle, #fff 0 34%, var(--collect-color, #f3cf6a) 36% 68%, transparent 72%);
    animation: artifact-collect-sparkle calc(var(--collect-duration, 950ms) * 0.62) ease-out forwards;
    animation-delay: var(--spark-delay, 0ms);
}

@keyframes artifact-collect-coin {
    0% {
        opacity: 0;
        transform: translate(0, 8px) scale(0.4) rotate(-14deg);
    }

    /* Pop off the cell. */
    9% {
        opacity: 1;
        transform: translate(0, -2px) scale(1.5) rotate(7deg);
    }

    15% {
        transform: translate(0, 0) scale(1.18) rotate(-5deg);
    }

    /* Fly to the centre of the screen, growing on the way. */
    38% {
        transform: translate(var(--fly-x, 0px), var(--fly-y, -60px)) scale(2.2) rotate(4deg);
    }

    /* Held celebration pulse at the centre. */
    48% {
        transform: translate(var(--fly-x, 0px), var(--fly-y, -60px)) scale(1.96) rotate(-3deg);
    }

    60% {
        transform: translate(var(--fly-x, 0px), var(--fly-y, -60px)) scale(2.12) rotate(2deg);
    }

    76% {
        opacity: 1;
        transform: translate(var(--fly-x, 0px), var(--fly-y, -60px)) scale(2.02) rotate(0deg);
    }

    /* Drift up and fade out. */
    100% {
        opacity: 0;
        transform: translate(var(--fly-x, 0px), calc(var(--fly-y, -60px) - 84px)) scale(1.4) rotate(0deg);
    }
}

@keyframes artifact-collect-halo {
    0% {
        opacity: 0.9;
        transform: scale(0.55);
    }

    100% {
        opacity: 0;
        transform: scale(2.3);
    }
}

@keyframes artifact-collect-ring {
    0% {
        opacity: 0;
        transform: scale(0.35);
    }

    18% {
        opacity: 0.85;
    }

    100% {
        opacity: 0;
        transform: scale(2.7);
    }
}

@keyframes artifact-collect-sparkle {
    0% {
        opacity: 0;
        transform: rotate(var(--spark-angle, 0deg)) translateX(0) scale(0.2);
    }

    22% {
        opacity: 1;
        transform: rotate(var(--spark-angle, 0deg)) translateX(70%) scale(1);
    }

    100% {
        opacity: 0;
        transform: rotate(var(--spark-angle, 0deg)) translateX(190%) scale(0.3);
    }
}

/* A matched tile that is about to fly to a goal badge vanishes instantly
 * instead of playing the normal shrink-out (.tile-destroy) — the flying copy
 * (.goal-fly-item, spawned at the same instant) takes over the motion, so the
 * hand-off reads as one continuous move rather than "shrink, then a copy flies". */
.tile.tile-fly-source {
    opacity: 0;
}

/* Full-viewport stage for the goal-fly tiles. Sits above the goals panel
 * (z-index 2) so a tile lands on top of its badge, but below the full-screen
 * overlays (z-index 4+) so a modal still covers everything. */
.goal-fly-layer {
    position: fixed;
    inset: 0;
    z-index: 3;
    pointer-events: none;
    overflow: visible;
}

/* A matched tile that feeds a goal (currently just the ladder colour) flies from
 * its cell to that goal's badge in the HUD instead of the artifact-collect burst,
 * which flies to screen centre.
 *
 * The motion itself is not here: buildGoalFlyKeyframes in main.js samples it and
 * hands it over as one keyframe list. The path is an arc because the horizontal
 * and vertical legs run on different easings — the horizontal starts fast and
 * finishes first, so the tile sweeps out across the board and only swings up
 * into the badge at the end, sagging below the straight line rather than lobbing
 * over it. CSS allows one easing per animation, so expressing that here took
 * three nested elements, one per leg, and every one of them became its own
 * composited layer for the length of the flight: fifteen tile-sized GPU
 * textures for a five-tile match. Sampled in JS it is one element, one layer,
 * the same picture.
 *
 * The item is sized in JS to the exact board cell it leaves, so it reads as the
 * tile itself lifting off rather than a small icon. */
.goal-fly-item {
    position: absolute;
    z-index: 4;
    pointer-events: none;
}

/* The board draws its tile art oversized past the cell (see .tile-art's 122%) —
 * match that so the flying copy is the same size as what was on the board, and
 * add a bloom in the tile's own colour so it glows on the way over. */
.goal-fly-item .goal-fly-img {
    position: absolute;
    left: 50%;
    top: 50%;
    width: 122%;
    height: 122%;
    background-position: center;
    background-repeat: no-repeat;
    background-size: contain;
    transform: translate(-50%, -50%);
    filter:
        drop-shadow(0 3px 6px rgb(0 0 0 / 34%))
        drop-shadow(0 0 9px color-mix(in srgb, var(--fly-glow, #f3cf6a) 70%, transparent));
}

.propeller-fly-item {
    position: absolute;
    z-index: 5;
    pointer-events: none;
}

.rocket-fly-item {
    position: absolute;
    z-index: 5;
    pointer-events: none;
}

/* A flying half of the rocket (launchRocketFlight). */
.rocket-fly-half {
    filter: drop-shadow(0 0 8px rgb(255 190 90 / 75%));
}

.propeller-fly-art {
    position: absolute;
    left: 50%;
    top: 50%;
    width: 122%;
    height: 122%;
    transform: translate(-50%, -50%);
    background-position: center;
    background-repeat: no-repeat;
    background-size: contain;
    filter:
        drop-shadow(0 4px 8px rgb(0 0 0 / 40%))
        drop-shadow(0 0 12px rgb(111 211 199 / 65%));
}

/* Punches the goal badge the instant a flying tile lands, so the counter's bump
 * reads as "that's what just arrived". Fires once per arrival, not once per move. */
.goal-card-pulse {
    animation: goal-card-pulse var(--goal-pulse-duration, 420ms) cubic-bezier(0.22, 1.4, 0.4, 1);
}

@keyframes goal-card-pulse {
    0% {
        transform: scale(1);
    }

    28% {
        transform: scale(1.26);
    }

    58% {
        transform: scale(0.96);
    }

    100% {
        transform: scale(1);
    }
}

/* Shockwave off the badge on each landing — the impact half of the punch. */
.goal-card-ring {
    position: absolute;
    left: 50%;
    top: 50%;
    width: 82%;
    aspect-ratio: 1;
    border-radius: 50%;
    border: 2px solid var(--ring-color, #f3cf6a);
    pointer-events: none;
    opacity: 0;
    animation: goal-card-ring var(--ring-duration, 420ms) cubic-bezier(0.16, 0.8, 0.27, 1) forwards;
}

@keyframes goal-card-ring {
    0% {
        opacity: 0.95;
        transform: translate(-50%, -50%) scale(0.4);
    }

    100% {
        opacity: 0;
        transform: translate(-50%, -50%) scale(2);
    }
}

.tile {
    position: relative;
    display: grid;
    place-items: center;
    min-width: 0;
    min-height: 0;
    /* Makes the cell a query container so the centred glyph labels below can be
       sized in cqw — i.e. as a share of the cell itself. The board is sized from
       --ui-px but clamped by viewport height too (see .board-wrap), so a cell is
       not a fixed multiple of --ui-px; only the cell's own width tracks it. */
    container-type: size;
    border: 0;
    border-radius: calc(12 * var(--ui-px));
    color: #1f1b22;
    background: var(--tile-color);
    box-shadow: 0 4px 6px rgb(43 35 28 / 16%);
    cursor: pointer;
    touch-action: none;
    transform: translateY(0);
    /* No `transform` here. WebKit starts a transition from the ANIMATED value
       when a fill-forwards animation's class goes away — render() dropping
       tile-swap slid the freshly swapped tiles back from their neighbour's
       square on iOS, and tile-destroy did the same from scale(0.58). Chrome
       snaps, which is what the board wants. Only the selection lift eases
       (.tile-selected), and the swap takes that class off before it starts. */
    transition: filter 110ms ease, box-shadow 110ms ease;
}

/* The cell's effects layer (createTileFxLayer in main.js): one transparent
 * overlay per tile that every glow and flash below animates instead of the tile
 * itself. It exists for one reason — `opacity` and `transform` are the only two
 * properties the compositor can animate on the GPU without asking the renderer
 * to paint the cell again. A keyframe that moves `filter` or `box-shadow` makes
 * the cell re-rasterise on EVERY frame, and at dpr 3 a cell is a 366x366
 * texture; a device trace of one cascade showed the GPU process 44% busy with
 * 46 tasks over 15ms while the main thread sat at 9%. Same reasoning that keeps
 * a drop-shadow off .tile-art.
 *
 * It paints above the art but is hollow: the glow is a box-shadow, which only
 * renders outside the border box, so what is underneath still reads. The inside
 * half is ::after, a flat tint for the flashes that used filter: brightness().
 * Both start at opacity 0, so a tile with no state active costs nothing. */
.tile-fx {
    position: absolute;
    inset: 0;
    z-index: 3;
    border-radius: inherit;
    pointer-events: none;
    opacity: 0;
}

.tile-fx::after {
    content: "";
    position: absolute;
    inset: 0;
    border-radius: inherit;
    background: #fff;
    opacity: 0;
}

.tile > span {
    position: relative;
    z-index: 1;
}

/*
 * Tile style variants. A body class (ts-flat / ts-gem / ts-clay / ts-jelly) skins
 * the plain colour tiles (.tile-normal) only — specials and cracks keep their own
 * art. Shades are derived from --tile-color via color-mix, so every variant works
 * for all six colours automatically. Switched live via the tileStyle() console
 * command; default is set in JS (applyTileStyle). The sheen sits on ::after.
 */
.tile-normal::after {
    content: "";
    position: absolute;
    pointer-events: none;
    z-index: 0;
}

/* Now — plain flat fill with a soft corner disc. */
body.ts-flat .tile-normal {
    box-shadow: 0 4px 8px rgb(36 31 25 / 14%);
}

body.ts-flat .tile-normal::after {
    top: 12%;
    left: 12%;
    width: 30%;
    aspect-ratio: 1;
    border-radius: 50%;
    background: rgb(255 255 255 / 30%);
}

/* B — soft clay: bright top edge, dark bottom lip, matte face. */
body.ts-clay .tile-normal {
    box-shadow:
        inset 0 2px 0 color-mix(in srgb, var(--tile-color) 58%, #fff),
        inset 0 -6px 0 color-mix(in srgb, var(--tile-color) 74%, #160f06),
        0 5px 10px rgb(36 31 25 / 18%);
}

body.ts-clay .tile-normal::after {
    top: 12%;
    left: 12%;
    width: 28%;
    aspect-ratio: 1;
    border-radius: 50%;
    background: rgb(255 255 255 / 26%);
}

/* A — glossy gem: top-lit gradient, inset bevel, a top gloss band. */
body.ts-gem .tile-normal {
    background: linear-gradient(
        180deg,
        color-mix(in srgb, var(--tile-color) 55%, #fff),
        var(--tile-color) 52%,
        color-mix(in srgb, var(--tile-color) 80%, #000));
    box-shadow:
        inset 0 2px 0 rgb(255 255 255 / 55%),
        inset 0 -4px 7px rgb(0 0 0 / 22%),
        0 5px 9px rgb(36 31 25 / 26%);
}

body.ts-gem .tile-normal::after {
    top: 6%;
    left: 10%;
    right: 10%;
    height: 40%;
    border-radius: 10px 10px 42% 42%;
    background: linear-gradient(180deg, rgb(255 255 255 / 52%), rgb(255 255 255 / 0%));
}

/* C — jelly: vivid radial fill, glassy rim, bright blob highlight. */
body.ts-jelly .tile-normal {
    background: radial-gradient(
        120% 90% at 50% 22%,
        color-mix(in srgb, var(--tile-color) 45%, #fff),
        var(--tile-color) 60%,
        color-mix(in srgb, var(--tile-color) 82%, #000));
    border: 1.5px solid rgb(255 255 255 / 28%);
    box-shadow:
        inset 0 2px 3px rgb(255 255 255 / 70%),
        inset 0 -5px 9px rgb(0 0 0 / 22%),
        0 6px 12px rgb(36 31 25 / 22%);
}

body.ts-jelly .tile-normal::after {
    top: 10%;
    left: 16%;
    width: 34%;
    height: 24%;
    border-radius: 50%;
    background: rgb(255 255 255 / 58%);
    filter: blur(0.5px);
}

/* Shape skins — the tile is a full SVG silhouette (createTileShapeSvg), so
   drop the square face entirely and let currentColor carry the tile colour. */
body.ts-crystal .tile-normal,
body.ts-garden .tile-normal,
body.ts-soft3d .tile-normal,
body.ts-ink .tile-normal {
    background: transparent;
    box-shadow: none;
    color: var(--tile-color);
}

body.ts-crystal .tile-normal::after,
body.ts-garden .tile-normal::after,
body.ts-soft3d .tile-normal::after,
body.ts-ink .tile-normal::after {
    display: none;
}

/* The shape is a layer, not a grid item: absolutely centered so a label sharing
   the tile (bomb charge) stacks on top instead of splitting the grid cell. */
.tile-shape {
    position: absolute;
    left: 50%;
    top: 50%;
    transform: translate(-50%, -50%);
    width: 94%;
    height: 94%;
    display: block;
    filter: drop-shadow(0 2px 3px rgb(0 0 0 / 24%));
}

/* Crystals read small (the silhouette leaves viewBox padding) and gain a gem-like
   translucency, so scale them up past the cell and let the scene show through.
   The room skin borrows the crystal gems for its specials, so it shares the
   treatment (its normal tiles are .tile-art PNGs, untouched by this). */
body.ts-room .tile-shape,
body.ts-crystal .tile-shape {
    width: 118%;
    height: 118%;
    opacity: 0.86;
    /* The pastel tile palette reads matte at gem scale — richen it for this skin. */
    filter: saturate(1.45) contrast(1.06) drop-shadow(0 3px 3px rgb(0 0 0 / 28%));
}

/* Soft3d / ink gems: same oversize as crystal (their cuts leave viewBox padding),
   but opaque and unfiltered — their shading is baked into the shapes. */
body.ts-soft3d .tile-shape,
body.ts-ink .tile-shape {
    width: 118%;
    height: 118%;
}

/* Shape-skin specials: bomb and wildcard render as gems, so drop the square
   face and its corner decorations. */
body.ts-crystal .tile.tile-bomb,
body.ts-crystal .tile.tile-wild,
body.ts-room .tile.tile-bomb,
body.ts-room .tile.tile-wild,
body.ts-soft3d .tile.tile-bomb,
body.ts-soft3d .tile.tile-wild,
body.ts-ink .tile.tile-bomb,
body.ts-ink .tile.tile-wild {
    background: transparent;
    box-shadow: none;
}

body.ts-crystal .tile.tile-bomb::before,
body.ts-crystal .tile.tile-wild::before,
body.ts-crystal .tile.tile-wild::after,
body.ts-room .tile.tile-bomb::before,
body.ts-room .tile.tile-wild::before,
body.ts-room .tile.tile-wild::after,
body.ts-soft3d .tile.tile-bomb::before,
body.ts-soft3d .tile.tile-wild::before,
body.ts-soft3d .tile.tile-wild::after,
body.ts-ink .tile.tile-bomb::before,
body.ts-ink .tile.tile-wild::before,
body.ts-ink .tile.tile-wild::after {
    display: none;
}

/* Cracked cells are shape art (frosted ice / geode) — drop the square face, its
   gradient crack art and the keepsake box's gold frame + corner star. */
body.ts-crystal .tile.tile-cracked,
body.ts-crystal .tile.tile-artifact-crack,
body.ts-room .tile.tile-cracked,
body.ts-room .tile.tile-artifact-crack,
body.ts-soft3d .tile.tile-cracked,
body.ts-soft3d .tile.tile-artifact-crack,
body.ts-ink .tile.tile-cracked,
body.ts-ink .tile.tile-artifact-crack {
    background: transparent;
    box-shadow: none;
}

/* Blocker art fills its viewBox edge-to-edge — keep it cell-sized, unlike the
   pointy gems that need the 118% boost. */
body.ts-crystal .tile.tile-cracked .tile-shape,
body.ts-room .tile.tile-cracked .tile-shape,
body.ts-soft3d .tile.tile-cracked .tile-shape,
body.ts-ink .tile.tile-cracked .tile-shape {
    width: 106%;
    height: 106%;
}

body.ts-crystal .tile.tile-artifact-crack::after,
body.ts-room .tile.tile-artifact-crack::after,
body.ts-soft3d .tile.tile-artifact-crack::after,
body.ts-ink .tile.tile-artifact-crack::after {
    display: none;
}

/* The charge number floats on the bomb gem's glowing core. */
body.ts-crystal .tile.tile-bomb span,
body.ts-room .tile.tile-bomb span,
body.ts-soft3d .tile.tile-bomb span,
body.ts-ink .tile.tile-bomb span {
    background: none;
    color: #fff;
    text-shadow: 0 1px 2px rgb(0 0 0 / 55%);
}

/* The rainbow gem IS the wildcard — no "*" needed. Keep the box for layout. */
body.ts-crystal .tile.tile-wild span,
body.ts-room .tile.tile-wild span,
body.ts-soft3d .tile.tile-wild span,
body.ts-ink .tile.tile-wild span {
    visibility: hidden;
}

/* Ready bomb: the square skin pulsed via box-shadow; here the gem itself pulses. */
body.ts-crystal .tile.tile-ready-bomb,
body.ts-room .tile.tile-ready-bomb,
body.ts-soft3d .tile.tile-ready-bomb,
body.ts-ink .tile.tile-ready-bomb {
    box-shadow: none;
}

/* A primed bomb pulses for as long as it stands on the board — this was the one
   infinite filter animation in the game, so it repainted its cell sixty times a
   second, per primed bomb, until the player spent it. Both halves of the pulse
   are now compositor work: the item scales (transform), and the glow is a
   second, STATIC drop-shadow that only fades in and out (opacity). A filter
   that never changes is rasterised once and then reused. */
body.ts-crystal .tile.tile-ready-bomb .tile-shape,
body.ts-soft3d .tile.tile-ready-bomb .tile-shape,
body.ts-ink .tile.tile-ready-bomb .tile-shape,
body.ts-room .tile.tile-ready-bomb .tile-art {
    animation: shape-bomb-ready 900ms ease-in-out infinite;
}

/* The room bomb is a painted PNG, so its glow has to trace the stick's alpha, not
   a rounded box. ::after is a second copy of the same art carrying nothing but
   the shadow; it sits exactly on top of the original, so fading it in adds the
   glow without doubling anything visible. */
body.ts-room .tile.tile-ready-bomb .tile-art::after {
    content: "";
    position: absolute;
    inset: 0;
    background-image: inherit;
    background-position: inherit;
    background-repeat: inherit;
    background-size: inherit;
    filter: drop-shadow(0 0 8px rgb(246 161 60 / 85%));
    animation: bomb-ready-glow 900ms ease-in-out infinite;
}

/* The gems are inline SVG, which takes no pseudo-element, so their halo rides
   the cell's effects layer instead: a round glow behind the gem rather than one
   traced along its facets. Crystal keeps the saturation push it already carries
   statically (see .tile-shape above) — the pulse no longer touches it. */
body.ts-crystal .tile.tile-ready-bomb .tile-fx,
body.ts-soft3d .tile.tile-ready-bomb .tile-fx,
body.ts-ink .tile.tile-ready-bomb .tile-fx {
    inset: 12%;
    border-radius: 50%;
    box-shadow: 0 0 calc(10 * var(--ui-px)) calc(2 * var(--ui-px)) rgb(246 161 60 / 85%);
    animation: bomb-ready-glow 900ms ease-in-out infinite;
}

@keyframes shape-bomb-ready {
    0%,
    100% {
        transform: translate(-50%, -50%) scale(1);
    }

    50% {
        transform: translate(-50%, -50%) scale(1.07);
    }
}

@keyframes bomb-ready-glow {
    0%,
    100% {
        opacity: 0.45;
    }

    50% {
        opacity: 1;
    }
}

@media (prefers-reduced-motion: reduce) {
    body.ts-crystal .tile.tile-ready-bomb .tile-shape,
    body.ts-soft3d .tile.tile-ready-bomb .tile-shape,
    body.ts-ink .tile.tile-ready-bomb .tile-shape,
    body.ts-room .tile.tile-ready-bomb .tile-art,
    body.ts-room .tile.tile-ready-bomb .tile-art::after,
    body.ts-crystal .tile.tile-ready-bomb .tile-fx,
    body.ts-soft3d .tile.tile-ready-bomb .tile-fx,
    body.ts-ink .tile.tile-ready-bomb .tile-fx {
        animation: none;
    }

    /* Still primed, just not moving: hold the glow at its bright end. */
    body.ts-room .tile.tile-ready-bomb .tile-art::after,
    body.ts-crystal .tile.tile-ready-bomb .tile-fx,
    body.ts-soft3d .tile.tile-ready-bomb .tile-fx,
    body.ts-ink .tile.tile-ready-bomb .tile-fx {
        opacity: 1;
    }
}

/* Room skin: each tile is a painted PNG workshop item (createTileArtImage in
   main.js) sitting on a soft sky-blue cell slot — the classic match-3 look of
   the concept sheet. Specials and cracks borrow the crystal gems/geode (wired
   into the crystal selector groups above) but share the same slot face, so the
   whole grid reads as one panel of cells. */
body.ts-room .tile,
body.ts-room .tile.tile-bomb,
body.ts-room .tile.tile-wild,
body.ts-room .tile.tile-cracked,
body.ts-room .tile.tile-artifact-crack {
    border-radius: calc(10 * var(--ui-px));
    background: linear-gradient(180deg, rgb(158 172 226 / 80%), rgb(136 151 208 / 76%));
    box-shadow:
        inset 0 1px 0 rgb(255 255 255 / 26%),
        inset 0 -2px 0 rgb(38 48 88 / 28%);
}

body.ts-room .tile-normal::after {
    display: none;
}

/* The art pads the item inside a square canvas — oversize past the cell so the
   item itself nearly fills its slot, the same trick the gem skins use.

   Painted as a background rather than an <img> (see createArtImage): a render
   rebuilds every tile, and a per-element image load blanks the tile for a frame
   on a cold cache. drop-shadow still traces the art's alpha, since the element
   carries nothing but the background. */
.tile-art {
    position: absolute;
    left: 50%;
    top: 50%;
    transform: translate(-50%, -50%);
    width: 122%;
    height: 122%;
    background-position: center;
    background-repeat: no-repeat;
    background-size: contain;
    pointer-events: none;
    user-select: none;
    /* No drop-shadow here on purpose. It traced the art's alpha, which looked
       right — but a filter is a per-frame GPU blur pass on its own render
       surface, and there is one of these in EVERY cell. A device trace of a
       single move showed the GPU process pinned at ~100% for 1.75s across the
       cascade while the main thread sat idle; 36-64 filtered surfaces animating
       at once is the shape of that. If the shadow comes back it should be baked
       into the art, not asked of the compositor sixty times a frame. */
}

/* On the board the propeller sits still; its blades only turn once it has been
 * tapped and takes off (.propeller-spinning on the flying copy). */
.tile-art-propeller {
    width: 112%;
    height: 112%;
    background-image: url("art/tiles/propeller_1.webp");
}

.propeller-spinning {
    animation: propeller-frames 0.1s steps(1) infinite;
}

/* The white a bomb burns up to as it winds up to blow (playBombFuse in
   main.js): a flat white cut to the art's own silhouette by a mask of the same
   image, so the flash is an opacity change rather than a filter. It rides inside
   the art, so it squashes and stretches with it. */
.tile-art-flash {
    position: absolute;
    inset: 0;
    background: #fff;
    -webkit-mask-position: center;
    mask-position: center;
    -webkit-mask-repeat: no-repeat;
    mask-repeat: no-repeat;
    -webkit-mask-size: contain;
    mask-size: contain;
    opacity: 0;
    pointer-events: none;
}

/* The rocket art stands up, the way a column rocket fires; a row rocket is the
   same art turned on its side — anticlockwise, so the bottom half points to +col
   the way it points to +row upright (see launchRocketFlight). */
.tile-art-rocket {
    width: 112%;
    height: 112%;
}

.tile-art-rocket-row {
    transform: translate(-50%, -50%) rotate(-90deg);
}

/* The orb (tile.js: orb) — a CSS placeholder until it has painted art: a
   rainbow ball, since it takes whichever colour it is pointed at. The rainbow
   turns slowly under a highlight that stays put. When the painted WebP lands this
   becomes a background-image like .tile-art-propeller and the pseudo-elements go.
   There are only ever one or two on a board, so the turning layer is affordable
   (see the note on .tile-art). */
.tile.tile-orb {
    cursor: pointer;
}

.tile-art-orb {
    width: 80%;
    height: 80%;
    border-radius: 50%;
    box-shadow: 0 3px 6px rgb(40 30 70 / 35%);
}

.tile-art-orb::before,
.tile-art-orb::after {
    content: "";
    position: absolute;
    inset: 0;
    border-radius: 50%;
}

.tile-art-orb::before {
    background: conic-gradient(#ff6b6b, #ffb347, #ffe66d, #6ee7a0, #5ec8ff, #8f7bff, #f27bff, #ff6b6b);
    animation: orb-turn 6s linear infinite;
}

.tile-art-orb::after {
    background:
        radial-gradient(circle at 34% 30%, rgb(255 255 255 / 95%) 0 9%, rgb(255 255 255 / 0%) 26%),
        radial-gradient(circle at 50% 50%, rgb(255 255 255 / 0%) 52%, rgb(40 20 80 / 40%) 100%);
    box-shadow: inset 0 0 0 2px rgb(255 255 255 / 55%);
}

@keyframes orb-turn {
    to {
        transform: rotate(1turn);
    }
}

/* Winding up to go off (playOrbVacuumFx in board-fx.js): the colours spin fast. */
.tile-art-orb.is-charging::before {
    animation-duration: 0.6s;
}

/* The specials' effects, drawn over the board in #goalFlyLayer (board-fx.js):
   a canvas per effect, and the copies of the tiles the orb pulls in, placed
   over the cells they came from. */
.board-fx-canvas {
    position: absolute;
    pointer-events: none;
}

/* The orb's tethers over the tiles they pull in, so a line still reaches its
   tile as the copy spins away instead of ending under it. */
.board-fx-canvas.orb-vacuum-canvas {
    z-index: 2;
}

.tile.orb-vacuum-copy {
    position: absolute;
    z-index: 1;
    transition: none;
    will-change: transform, opacity;
}

@media (prefers-reduced-motion: reduce) {
    .tile-art-orb::before {
        animation: none;
    }
}

/* A frozen tile (tile.js: frozen): the ordinary tile's art under a sheet of ice
   — a pale frosted pane with a bright rim and two glints, so the item still
   reads through it. Placeholder until painted art lands. Plain gradients and
   shadows only: no filter or backdrop-filter, there can be a whole board of
   these (see the note on .tile-art). */
.tile-ice {
    position: absolute;
    inset: 5%;
    border-radius: calc(9 * var(--ui-px));
    background:
        linear-gradient(135deg, rgb(255 255 255 / 55%) 0 14%, transparent 15% 60%, rgb(255 255 255 / 30%) 61% 66%, transparent 67%),
        linear-gradient(180deg, rgb(226 246 255 / 52%), rgb(150 205 238 / 42%));
    box-shadow:
        inset 0 0 0 2px rgb(255 255 255 / 78%),
        inset 0 -4px 8px rgb(80 140 200 / 38%),
        0 0 6px rgb(185 228 255 / 70%);
    pointer-events: none;
}

.tile.tile-frozen {
    cursor: not-allowed;
}

@keyframes propeller-frames {
    0%, 49.9% {
        background-image: url("art/tiles/propeller_1.webp");
    }
    50%, 100% {
        background-image: url("art/tiles/propeller_2.webp");
    }
}

@media (prefers-reduced-motion: reduce) {
    .propeller-spinning {
        animation: none;
    }
}

/* Painted keepsake art (ARTIFACT_TYPES.image). On the board it sits inside the
   revealed keepsake's coloured face, so it stays just under cell size and lets a
   rim of that face read as a frame. */
.artifact-art-tile {
    width: 90%;
    height: 90%;
    filter: drop-shadow(0 2px 4px rgb(0 0 0 / 30%));
}

/* In the collection grid and the collect burst the art is the whole icon. Both
   boxes are round and lay their content out themselves, so drop .tile-art's
   board-cell absolute placement and keep the art inside the circle. */
.collection-icon .artifact-art-card,
.artifact-collect-coin .artifact-art-coin {
    position: static;
    transform: none;
    width: 84%;
    height: 84%;
}

/* Blocker art (the plank barricade) should read as filling its cell rather than
   floating like an item, so let it run a little larger past the cell edge. */
.tile-art-crack {
    width: 132%;
    height: 132%;
}

/* The keepsake sealed inside a barricade (createCrackPeekElement). It sits BEHIND
   the plank art, just inside the frame, so the boards hide most of it and it
   reads through the gaps between them — then through the hole once they split.
   Kept dim and cool at first, warming and brightening with each hit so the prize
   surfaces as the blocker gives way. */
.tile-crack-peek {
    position: absolute;
    left: 50%;
    /* Sized and nudged up so the keepsake sits fully inside the barricade's
       frame (no edge pokes out past the boards) while its best part — for the
       Drawing, the sun and the roof — lands behind the plank gaps and the hole
       that opens in the top board. */
    top: 44%;
    transform: translate(-50%, -50%);
    width: 110%;
    height: 110%;
    pointer-events: none;
    user-select: none;
    filter: brightness(0.7) saturate(0.85);
    transition: filter 260ms ease;
}

.tile.crack-hp-2 .tile-crack-peek {
    filter: brightness(0.88) saturate(1);
}

.tile.crack-hp-1 .tile-crack-peek {
    filter: brightness(1) saturate(1.05) drop-shadow(0 0 6px rgb(255 224 150 / 55%));
}

/* Emoji keepsakes have no painted art — centre the symbol in the same slot and
   drop the crack tile's usual pill background (the selector has to outweigh
   `.tile.tile-artifact-crack span`, which paints that pill). */
.tile.tile-cracked.tile-artifact-crack .tile-crack-peek-symbol {
    /* `.tile > span` pins spans to position: relative — restate the absolute
       placement here so the symbol lands in the same slot as the painted art. */
    position: absolute;
    z-index: 0;
    display: flex;
    align-items: center;
    justify-content: center;
    width: 74%;
    height: 74%;
    background: none;
    font-size: 46cqw;
    line-height: 1;
}

.tile.tile-selected {
    outline: 4px solid rgb(32 32 38 / 36%);
    outline-offset: -4px;
    filter: brightness(1.06) saturate(1.08);
    transform: translateY(-2px);
    transition: transform 110ms ease, filter 110ms ease, box-shadow 110ms ease;
}

/* A tile in flight rides over its neighbours. The motion itself is script now —
   buildTileAppearAnimation / buildTileFallAnimation in main.js — because a class
   cannot replay an animation without the browser first seeing the element
   without it, and buying that took a forced style pass on every render. */
.tile.tile-appear,
.tile.tile-fall {
    z-index: 1;
}

.tile.tile-swap {
    animation: tile-swap var(--swap-duration, 150ms) cubic-bezier(0.2, 0.86, 0.28, 1) forwards;
    z-index: 2;
}

.tile.tile-swap-reject {
    animation: tile-swap-reject var(--swap-duration, 230ms) cubic-bezier(0.2, 0.86, 0.28, 1) both;
    z-index: 2;
}

.tile.tile-bomb {
    color: #fffaf0;
    background:
        radial-gradient(circle at 30% 25%, rgb(255 255 255 / 42%) 0 12%, transparent 13%),
        #33313c;
    cursor: pointer;
    box-shadow:
        inset 0 -7px 0 rgb(0 0 0 / 16%),
        inset 0 2px 0 rgb(255 255 255 / 42%),
        inset 0 0 0 4px rgb(32 32 38 / 24%),
        0 7px 12px rgb(43 35 28 / 16%);
}

.tile.tile-bomb::before {
    content: "";
    position: absolute;
    top: 11%;
    right: 16%;
    width: 20%;
    height: 20%;
    border-top: 3px solid #f1c75b;
    border-right: 3px solid #f1c75b;
    border-radius: 0 8px 0 0;
}

.tile.tile-ready-bomb {
    box-shadow:
        inset 0 -7px 0 rgb(0 0 0 / 16%),
        inset 0 2px 0 rgb(255 255 255 / 42%),
        inset 0 0 0 4px rgb(241 199 91 / 42%),
        0 0 0 3px rgb(241 199 91 / 24%),
        0 9px 18px rgb(43 35 28 / 20%);
}

.tile.tile-ready-bomb span {
    color: #33313c;
    background: #f1c75b;
}

.tile.tile-propeller,
.tile.tile-rocket {
    color: #fffaf0;
    cursor: pointer;
    background:
        radial-gradient(circle at 30% 25%, rgb(255 255 255 / 45%) 0 12%, transparent 13%),
        linear-gradient(135deg, rgb(255 255 255 / 30%), rgb(255 255 255 / 0%) 40%),
        var(--tile-color);
    box-shadow:
        inset 0 -7px 0 rgb(0 0 0 / 16%),
        inset 0 2px 0 rgb(255 255 255 / 42%),
        inset 0 0 0 4px rgb(255 255 255 / 24%),
        0 7px 12px rgb(43 35 28 / 16%);
}

.tile.tile-wild {
    background:
        radial-gradient(circle at 22% 22%, rgb(255 255 255 / 72%) 0 10%, transparent 11%),
        radial-gradient(circle at 76% 74%, rgb(255 255 255 / 52%) 0 8%, transparent 9%),
        linear-gradient(135deg, rgb(255 255 255 / 42%), rgb(255 255 255 / 0%) 42%),
        var(--tile-color);
    box-shadow:
        inset 0 -7px 0 rgb(0 0 0 / 12%),
        inset 0 2px 0 rgb(255 255 255 / 48%),
        inset 0 0 0 4px rgb(255 255 255 / 34%),
        0 7px 12px rgb(43 35 28 / 14%);
}

.tile.tile-wild::before,
.tile.tile-wild::after {
    content: "";
    position: absolute;
    width: 18%;
    height: 18%;
    background: rgb(255 255 255 / 82%);
    clip-path: polygon(50% 0, 61% 39%, 100% 50%, 61% 61%, 50% 100%, 39% 61%, 0 50%, 39% 39%);
}

.tile.tile-wild::before {
    top: 10%;
    right: 13%;
}

.tile.tile-wild::after {
    left: 12%;
    bottom: 13%;
    transform: scale(0.72);
}

.tile.tile-cracked {
    color: #f7f4ee;
    background: #7c838d;
    cursor: not-allowed;
    /*
     * Cracks are immovable barriers in the collapse, so a new tile spawning into
     * the segment just below one starts its fall on the crack's cell. Stack cracks
     * above the falling/spawning tiles (z-index 1) so those tiles slide in behind
     * the crack instead of passing over it.
     */
    z-index: 2;
    box-shadow:
        inset 0 -7px 0 rgb(0 0 0 / 18%),
        inset 0 2px 0 rgb(255 255 255 / 24%),
        0 7px 12px rgb(43 35 28 / 12%);
}

/* Crack severity shown visually: more cracks as the tile weakens. */
.tile.tile-cracked.crack-hp-3 {
    background:
        linear-gradient(118deg, transparent 0 47%, rgb(32 32 38 / 5%) 48% 50%, transparent 51%),
        linear-gradient(135deg, transparent 0 45%, rgb(32 32 38 / 5%) 46% 49%, transparent 50%),
        #7c838d;
}

.tile.tile-cracked.crack-hp-2 {
    background:
        linear-gradient(135deg, transparent 0 44%, rgb(32 32 38 / 54%) 45% 48%, transparent 49%),
        linear-gradient(35deg, transparent 0 57%, rgb(32 32 38 / 48%) 58% 61%, transparent 62%),
        #7c838d;
}

.tile.tile-cracked.crack-hp-1 {
    background:
        linear-gradient(135deg, transparent 0 40%, rgb(28 28 34 / 64%) 41% 45%, transparent 46%),
        linear-gradient(35deg, transparent 0 53%, rgb(28 28 34 / 58%) 54% 58%, transparent 59%),
        linear-gradient(85deg, transparent 0 62%, rgb(28 28 34 / 52%) 63% 66%, transparent 67%),
        #8a8089;
}

.tile.tile-artifact-crack {
    color: #fff6e2;
    background:
        radial-gradient(circle at 50% 32%, rgb(255 255 255 / 30%) 0 9%, transparent 10%),
        linear-gradient(135deg, #b8894c 0 48%, #97662f 52% 100%);
    box-shadow:
        inset 0 -7px 0 rgb(0 0 0 / 18%),
        inset 0 2px 0 rgb(255 255 255 / 30%),
        inset 0 0 0 3px rgb(247 215 123 / 38%),
        0 0 0 2px rgb(212 160 32 / 40%),
        0 7px 14px rgb(43 35 28 / 16%);
}

.tile.tile-artifact-crack::after {
    content: "✦";
    position: absolute;
    top: 6%;
    right: 9%;
    color: #f7d77b;
    font-size: 26%;
    line-height: 1;
    text-shadow: 0 1px 2px rgb(0 0 0 / 35%);
}

.tile.tile-artifact-crack span {
    background: rgb(0 0 0 / 22%);
    color: #fff6e2;
}

.tile.tile-artifact {
    cursor: not-allowed;
    background:
        radial-gradient(circle at 50% 116%, rgb(255 255 255 / 50%) 0 32%, transparent 40%),
        radial-gradient(circle at 28% 24%, rgb(255 255 255 / 60%) 0 12%, transparent 13%),
        var(--tile-color);
    box-shadow:
        inset 0 -7px 0 rgb(0 0 0 / 12%),
        inset 0 2px 0 rgb(255 255 255 / 56%),
        inset 0 0 0 3px rgb(255 255 255 / 42%),
        0 0 0 2px rgb(255 255 255 / 36%),
        0 8px 18px rgb(43 35 28 / 20%);
}

/* The keepsake sits on the board as a bare emoji with no tile face behind it, so
   it needs to run larger than the other glyphs to carry the same weight as the
   painted PNG items around it (see .tile-art's 122%). */
.tile.tile-artifact span {
    background: transparent;
    color: #1f1b22;
    font-size: clamp(1.1rem, 7vw, 2.2rem);
    font-size: 74cqw;
    /* Full-cell box (it has no tinted face to keep small, unlike the bomb and
       blocker glyphs) so the oversized keepsake emoji fits inside it and centres
       on the cell instead of overflowing. */
    width: 100%;
    text-shadow: 0 1px 2px rgb(0 0 0 / 22%);
}

/* The brightness pop moved onto the effects layer as a tint (see .tile-fx): a
   keepsake is collected during the cascade, where every repainted cell competes
   with the rest of the board for the same GPU. */
@keyframes artifact-collect {
    0% {
        opacity: 1;
        transform: scale(1);
    }

    60% {
        opacity: 1;
        transform: scale(1.22);
    }

    100% {
        opacity: 0;
        transform: scale(0.5);
    }
}

@keyframes artifact-collect-flash {
    0% {
        opacity: 0;
    }

    60% {
        opacity: 0.26;
    }

    100% {
        opacity: 0;
    }
}

.tile.tile-artifact.tile-destroy {
    animation: artifact-collect var(--tile-destroy-duration, 170ms) ease-out forwards;
}

.tile.tile-artifact.tile-destroy .tile-fx::after {
    animation: artifact-collect-flash var(--tile-destroy-duration, 170ms) ease-out forwards;
}

.tile.tile-artifact.artifact-reveal {
    z-index: 2;
}

/* The halo was a box-shadow keyframe on the cell, i.e. a repaint per frame for
   560ms. It is now a fixed shadow on the effects layer that the reveal fades
   through (buildArtifactRevealHaloAnimation in main.js). */
.tile.tile-artifact.artifact-reveal .tile-fx {
    box-shadow: 0 0 0 10px rgb(255 230 130 / 36%), 0 0 24px 8px rgb(255 222 120 / 60%);
}

/* Hover only where there is a real pointer: iOS emulates :hover on tap and
   leaves it stuck on the cell, which then sat 1px up and snapped there at the
   end of every fall and pop (their keyframes end on translate(0)). */
@media (hover: hover) {
    .tile:hover {
        filter: brightness(1.04) saturate(1.04);
        outline: 3px solid rgb(32 32 38 / 18%);
        outline-offset: -3px;
        transform: translateY(-1px);
    }
}

.tile:focus-visible {
    filter: brightness(1.04) saturate(1.04);
    outline: 3px solid rgb(32 32 38 / 18%);
    outline-offset: -3px;
    transform: translateY(-1px);
}

.tile:active {
    transform: translateY(2px) scale(0.98);
}

.tile.tile-swap-from {
    z-index: 3;
    filter: brightness(1.1) saturate(1.08);
}

.tile.tile-swap-to {
    z-index: 2;
    filter: brightness(0.94) saturate(0.96);
}

/* Glyph labels are sized in cqw — a share of the cell (see .tile's container-type)
   — so they scale with the board like the PNG/SVG tile art does. They used to be
   clamped rem/vw values, which stopped tracking the cell once the board moved to
   --ui-px: the clamp pinned every glyph at its 2.2rem ceiling while the cells
   around it grew, leaving glyph tiles (notably the artifact) visibly undersized
   next to their painted neighbours. The rem clamp is kept as the first
   declaration so browsers without container queries still get the old sizing. */
.tile span {
    display: grid;
    place-items: center;
    width: 58%;
    aspect-ratio: 1;
    color: rgb(255 255 255 / 94%);
    font-size: clamp(1rem, 7vw, 2.2rem);
    font-size: 56cqw;
    font-weight: 900;
    line-height: 1;
    /* A glyph wider than this 58% box (the emoji skins) would otherwise overflow
       to one side only — start-aligned — and sit visibly off-centre in the cell. */
    text-align: center;
    text-shadow: 0 1px 1px rgb(0 0 0 / 18%);
}

.tile span svg {
    width: 100%;
    height: 100%;
    display: block;
    fill: rgb(255 255 255 / 92%);
}

.tile.tile-bomb span,
.tile.tile-wild span {
    background: rgb(32 32 38 / 22%);
    font-size: clamp(0.95rem, 6vw, 1.9rem);
    font-size: 48cqw;
}

.tile.tile-bomb span {
    background: rgb(255 255 255 / 15%);
    font-size: clamp(1rem, 6.4vw, 2rem);
    font-size: 51cqw;
}

.tile.tile-cracked span {
    background: rgb(32 32 38 / 24%);
    font-size: clamp(1rem, 6.4vw, 2rem);
    font-size: 51cqw;
}

.tile.invalid {
    animation: invalid var(--invalid-duration, 240ms) ease;
}

.tile.tile-destroy {
    animation: tile-destroy var(--tile-destroy-duration, 170ms) ease-in forwards;
}

/* Split in two: the squash stays on the cell (transform is free), the flash
   moved to the effects layer as a white tint. It used to be filter:brightness
   on the tile, which repainted every hit cell every frame — and hits land in
   the middle of a cascade, exactly when the GPU has the least room. */
.tile.tile-hit {
    animation: tile-hit var(--tile-hit-duration, 170ms) ease;
}

.tile.tile-hit .tile-fx::after {
    animation: tile-hit-flash var(--tile-hit-duration, 170ms) ease;
}

/* The brief holds the stage alone: the board steps back the same way it does for
   the restoration reveal, then eases in once the line has been read. */
body.is-briefing .board-panel {
    opacity: 0;
    transform: translateY(6%) scale(0.94);
    pointer-events: none;
}

/* Same landscape swap as the reveal (see .board-wrap's transition): the panel
   only fades, the board takes the step back, the pinned Undo/Hint stay put. */
@media (min-aspect-ratio: 1 / 1) {
    body.is-briefing .board-panel {
        transform: none;
    }

    body.is-briefing .board-wrap {
        transform: translateY(6%) scale(0.94);
    }
}

.status {
    min-height: 1.1em;
    margin: 0;
    color: var(--muted);
    font-size: calc(13 * var(--ui-px));
    font-weight: 700;
    text-align: center;
}

.status-row {
    min-height: calc(34 * var(--ui-px));
    display: grid;
    grid-template-columns: auto minmax(0, 1fr) auto;
    align-items: center;
    gap: calc(8 * var(--ui-px));
    width: min(100%, calc(524 * var(--ui-px)));
}

/* Landscape puts this row at the foot of the left rail instead of under the
   board — see the rail block up by .board-wrap. Bottom-left is where the left
   thumb rests while the right one plays, and getting the buttons off the board's
   bottom edge is what frees the height the board grew into. The row restacks to
   the rail's width: Undo and Hint side by side, the status line floating above
   them. */
@media (min-aspect-ratio: 1 / 1) {
    .status-row {
        position: fixed;
        z-index: 2;
        left: max(calc(12 * var(--ui-px)), env(safe-area-inset-left));
        bottom: max(calc(10 * var(--ui-px)), env(safe-area-inset-bottom));
        width: var(--rail-width);
        grid-template-columns: 1fr 1fr;
        grid-template-areas: "undo hint";
        gap: calc(6 * var(--ui-px));
    }

    /* Floats above the buttons as a toast instead of taking a row: its height
       changes with what it says, and the rail is fitted to the foot's height
       (fitRail in main.js), so a notice in the flow rescaled the whole rail
       every time one came and went. */
    .status-row .status {
        position: absolute;
        left: 0;
        right: 0;
        bottom: calc(100% + 6 * var(--ui-px));
    }

    .status-row .undo-button {
        grid-area: undo;
    }

    /* Half the rail is too narrow for "Подсказка" beside its icon, and the
       fixed-height button used to wrap the label out of its own box. Out here
       the icon sits on top of the label instead, in both buttons alike. */
    .status-row .undo-button,
    .status-row .hint-button {
        flex-direction: column;
        gap: calc(1 * var(--ui-px));
        height: calc(44 * var(--ui-px));
        padding: 0 calc(4 * var(--ui-px));
        font-size: calc(12 * var(--ui-px));
    }

    .status-row .hint-button {
        grid-area: hint;
    }

    /* The line has no panel behind it out here — it reads straight off the room
       art — so it brings its own, but only while it has something to say: the
       element keeps its height when empty (see .status above) and a blank pill
       hanging over the floorboards is worse than no line at all. */
    .status-row .status:not(:empty) {
        padding: calc(4 * var(--ui-px)) calc(8 * var(--ui-px));
        border-radius: calc(10 * var(--ui-px));
        color: var(--ink);
        background: rgb(255 253 247 / 90%);
        box-shadow: 0 6px 16px rgb(39 35 28 / 20%);
    }
}

.undo-button {
    min-width: 0;
    padding: 0 calc(12 * var(--ui-px));
}

/* Undo and Hint: icon first, then the label, never wrapped — the button has a
   fixed height, so a wrapped label spills out of it. */
.undo-button,
.hint-button {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    gap: calc(5 * var(--ui-px));
    white-space: nowrap;
}

.btn-icon {
    line-height: 1;
}

.btn-label {
    min-width: 0;
    overflow: hidden;
    text-overflow: ellipsis;
}

.undo-button[disabled] {
    opacity: 0.4;
}

/* Sits in the status row's trailing slot — the same shape as Undo so the two
   read as a matched pair with the status line held between them. Warm gold
   rather than the neutral shell: it is the one control that spends something
   (a rewarded ad) instead of taking something back. */
.hint-button {
    position: relative;
    min-width: 0;
    padding: 0 calc(12 * var(--ui-px));
    color: #7a5a12;
    border-color: rgb(190 150 60 / 55%);
    background: linear-gradient(180deg, #fffaeb, #f7e9c6);
}

.hint-button[disabled] {
    opacity: 0.4;
}

/* The "this plays an ad" mark: a small video-play chip pinned to the button's
   top-right corner, the convention players already read as rewarded video. */
.btn-ad-badge {
    position: absolute;
    top: calc(-7 * var(--ui-px));
    right: calc(-7 * var(--ui-px));
    display: grid;
    place-items: center;
    width: calc(18 * var(--ui-px));
    height: calc(18 * var(--ui-px));
    border: calc(1.5 * var(--ui-px)) solid #fffaeb;
    border-radius: 50%;
    background: linear-gradient(180deg, #e0a632, #b9791a);
    box-shadow: 0 2px 5px rgb(90 60 10 / 35%);
    pointer-events: none;
}

.btn-ad-badge svg {
    width: 70%;
    height: 70%;
    fill: #fff;
}

/* The pointed-at move. Deliberately louder than .tile-selected — it has to be
   findable at a glance on a full board — and it keeps pulsing until the move is
   played, because the player paid an ad for it. */
/* The outline is static; only the glow breathes, and it breathes on .tile-fx so
   the cell itself is never repainted. The old keyframe also pushed brightness
   and saturation on the tile — the glow alone carries the read, and a filter
   here ran on a cell that is on screen for as long as the hint lasts. */
.tile.tile-hint {
    outline: calc(4 * var(--ui-px)) solid rgb(233 183 66 / 90%);
    outline-offset: calc(-4 * var(--ui-px));
    z-index: 2;
}

.tile.tile-hint .tile-fx {
    box-shadow: 0 0 calc(14 * var(--ui-px)) rgb(233 183 66 / 70%);
    animation: tile-hint 1100ms ease-in-out infinite;
}

@keyframes tile-hint {
    0%,
    100% {
        opacity: 0;
    }

    50% {
        opacity: 1;
    }
}

@media (prefers-reduced-motion: reduce) {
    .tile.tile-hint .tile-fx {
        animation: none;
    }
}

/* A level's scripted opening (LEVEL LESSONS in main.js): the screen dims around
   the squares the move needs. Two layers do it. The shade is the ring's own
   spread shadow, so the hole is exactly the ring. Presses are the dim's job: it
   is transparent, with the same hole cut by clip-path — which clips
   hit-testing, so presses in the hole reach the tiles and presses anywhere else
   land on it. (Chrome would not paint a background through that evenodd path,
   which is why the shade is not the dim's.) Above the board and the HUD, under
   the full screens (z 4). */
.lesson-layer {
    position: fixed;
    inset: 0;
    z-index: 3;
    pointer-events: none;
    transition: opacity 220ms ease, visibility 0s linear 0s;
}

.lesson-layer.is-hidden {
    opacity: 0;
    visibility: hidden;
    transition: opacity 160ms ease, visibility 0s linear 160ms;
}

.lesson-dim {
    position: absolute;
    inset: 0;
    pointer-events: auto;
    touch-action: none;
}

.lesson-ring {
    position: fixed;
    box-shadow: 0 0 0 200vmax rgb(14 11 16 / 64%);
}

.lesson-ring::after {
    content: "";
    position: absolute;
    inset: 0;
    border: calc(3 * var(--ui-px)) solid rgb(246 204 102 / 95%);
    border-radius: inherit;
    box-shadow:
        0 0 0 calc(1 * var(--ui-px)) rgb(80 52 10 / 60%),
        0 0 calc(22 * var(--ui-px)) rgb(233 183 66 / 55%);
    animation: lesson-ring 1400ms ease-in-out infinite;
}

.lesson-ring.is-nudged::after {
    animation: lesson-ring-nudge 380ms ease, lesson-ring 1400ms ease-in-out 380ms infinite;
}

@keyframes lesson-ring {
    0%,
    100% {
        box-shadow:
            0 0 0 calc(1 * var(--ui-px)) rgb(80 52 10 / 60%),
            0 0 calc(14 * var(--ui-px)) rgb(233 183 66 / 35%);
    }

    50% {
        box-shadow:
            0 0 0 calc(1 * var(--ui-px)) rgb(80 52 10 / 60%),
            0 0 calc(30 * var(--ui-px)) rgb(233 183 66 / 75%);
    }
}

@keyframes lesson-ring-nudge {
    0%,
    100% {
        transform: scale(1);
    }

    40% {
        transform: scale(1.06);
    }
}

.lesson-caption {
    position: fixed;
    box-sizing: border-box;
    width: max-content;
    max-width: min(calc(320 * var(--ui-px)), calc(100vw - 32px));
    margin: 0;
    padding: calc(10 * var(--ui-px)) calc(16 * var(--ui-px));
    border: calc(1.5 * var(--ui-px)) solid rgb(246 204 102 / 70%);
    border-radius: calc(14 * var(--ui-px));
    background: linear-gradient(180deg, rgb(58 44 38 / 96%), rgb(34 25 24 / 96%));
    box-shadow:
        inset 0 calc(1 * var(--ui-px)) 0 rgb(255 236 190 / 18%),
        0 calc(8 * var(--ui-px)) calc(22 * var(--ui-px)) rgb(0 0 0 / 40%);
    color: #fff4dc;
    font-size: calc(15 * var(--ui-px));
    font-weight: 800;
    line-height: 1.3;
    text-align: center;
    text-wrap: balance;
}

.lesson-caption:empty {
    display: none;
}

@media (prefers-reduced-motion: reduce) {
    .lesson-ring::after,
    .lesson-ring.is-nudged::after {
        animation: none;
    }
}

.game-over-panel,
.world-screen,
.collection-screen,
.hub-screen,
.menu-sheet,
.confirm-panel {
    position: fixed;
    inset: 0;
    z-index: 4;
    display: grid;
    place-items: center;
    padding: calc(20 * var(--ui-px));
    background: rgb(32 32 38 / 24%);
}

.game-over-panel,
.confirm-panel {
    background: rgb(28 24 26 / 42%);
}

/* Reset confirmation sits above the World hub it is launched from. */
.confirm-panel {
    z-index: 6;
}

.game-over-panel.is-hidden,
.world-screen.is-hidden,
.collection-screen.is-hidden,
.hub-screen.is-hidden,
.menu-sheet.is-hidden,
.confirm-panel.is-hidden {
    display: none;
}

.game-over-panel-body,
.world-screen-body,
.collection-screen-body,
.hub-screen-body,
.menu-sheet-body,
.confirm-panel-body {
    /* Reference pixels (see --ui-px), like the HUD and board: the dialogs hold
       .icon-buttons, which scale, so a fixed-px shell around them would disagree
       with its own contents at every scale but the authored one — at the touch
       scale the buttons outgrew the shell and their labels broke mid-phrase. */
    width: min(100%, calc(320 * var(--ui-px)));
    display: grid;
    justify-items: center;
    gap: calc(12 * var(--ui-px));
    padding: calc(18 * var(--ui-px));
    border: 1px solid var(--line);
    border-radius: calc(8 * var(--ui-px));
    color: var(--ink);
    background: #fffaf0;
    box-shadow: var(--shadow);
}

/* Options menu: full-width stacked rows. */
.menu-sheet-body {
    gap: calc(8 * var(--ui-px));
}

.menu-title {
    font-size: calc(11 * var(--ui-px));
    font-weight: 800;
    letter-spacing: 0.1em;
    text-transform: uppercase;
    color: var(--muted);
}

.menu-row {
    width: 100%;
    height: calc(42 * var(--ui-px));
    justify-self: stretch;
}

.menu-sheet-body .icon-button.primary {
    width: 100%;
    height: calc(42 * var(--ui-px));
    margin-top: calc(4 * var(--ui-px));
}

.hub-screen-body {
    width: min(100%, calc(460 * var(--ui-px)));
    max-height: calc(100svh - 28 * var(--ui-px));
    overflow: hidden auto;
}

.hub-top {
    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: calc(12 * var(--ui-px));
    width: 100%;
    color: var(--muted);
    font-size: calc(13 * var(--ui-px));
    font-weight: 800;
    text-transform: uppercase;
}

.hub-grid {
    display: grid;
    /* One landscape card per row — two abreast squeezes the wide art too small. */
    grid-template-columns: 1fr;
    gap: calc(12 * var(--ui-px));
    width: 100%;
}

.hub-actions {
    display: flex;
    gap: calc(10 * var(--ui-px));
    width: 100%;
}

.hub-actions .icon-button {
    flex: 1;
    height: calc(40 * var(--ui-px));
}

.hub-card {
    position: relative;
    /* Landscape, matching the scene paintings (the thumbnail is the scene at its
       current stage, set inline by renderHub). */
    aspect-ratio: 16 / 9;
    border: 1px solid var(--line);
    border-radius: calc(12 * var(--ui-px));
    overflow: hidden;
    padding: 0;
    cursor: pointer;
    background-repeat: no-repeat;
    background-size: cover;
    background-position: center;
    background-color: #241f1c;
}

.hub-card.is-locked {
    cursor: default;
}

.hub-card.is-active {
    border: 2px solid #3f7d90;
}

.hub-chip {
    position: absolute;
    top: calc(8 * var(--ui-px));
    left: calc(8 * var(--ui-px));
    padding: calc(3 * var(--ui-px)) calc(9 * var(--ui-px));
    border-radius: 999px;
    background: rgb(255 250 240 / 94%);
    color: #33313c;
    font-size: calc(11 * var(--ui-px));
    font-weight: 800;
    text-transform: uppercase;
    letter-spacing: 0.04em;
}

.hub-chip.is-done {
    color: #3b6d11;
}

.hub-dim {
    position: absolute;
    inset: 0;
    background: rgb(34 28 24 / 42%);
}

.hub-lock {
    position: absolute;
    inset: 0 0 24% 0;
    display: grid;
    place-items: center;
    font-size: calc(27 * var(--ui-px));
}

.hub-bar {
    position: absolute;
    left: 0;
    right: 0;
    bottom: 0;
    display: grid;
    gap: calc(5 * var(--ui-px));
    padding: calc(8 * var(--ui-px)) calc(10 * var(--ui-px)) calc(10 * var(--ui-px));
    background: rgb(255 250 240 / 95%);
    text-align: left;
}

.hub-bar b {
    font-size: calc(14 * var(--ui-px));
    font-weight: 800;
    color: var(--ink);
    line-height: 1.1;
}

.hub-progress {
    font-size: calc(12 * var(--ui-px));
    font-weight: 700;
    color: var(--muted);
}

.hub-hint {
    font-size: calc(11 * var(--ui-px));
    color: var(--muted);
}

.world-screen {
    padding: 0;
    background: rgb(32 32 38 / 55%);
}

.world-screen-body {
    /* Full viewport: the landscape paintings want every pixel of width. */
    width: 100%;
    height: 100svh;
    max-height: 100svh;
    padding: 0;
    border: 0;
    border-radius: 0;
    background: none;
    box-shadow: none;
    position: relative;
    overflow: hidden;
    display: block;
}

.world-topbar {
    position: absolute;
    top: max(calc(10 * var(--ui-px)), env(safe-area-inset-top));
    left: 0;
    right: 0;
    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: calc(8 * var(--ui-px));
    padding: 0 calc(12 * var(--ui-px));
    pointer-events: none;
}

.world-back,
.world-meta {
    pointer-events: auto;
    border: 1px solid var(--line);
    border-radius: 999px;
    background: rgb(255 250 240 / 92%);
    box-shadow: 0 6px 16px rgb(36 31 25 / 18%);
}

.world-back {
    height: calc(34 * var(--ui-px));
    padding: 0 calc(14 * var(--ui-px));
    color: var(--ink);
    font-weight: 800;
    cursor: pointer;
}

.world-meta {
    display: flex;
    align-items: center;
    gap: calc(8 * var(--ui-px));
    height: calc(34 * var(--ui-px));
    padding: 0 calc(14 * var(--ui-px));
}

.world-meta span {
    font-size: calc(13 * var(--ui-px));
    font-weight: 800;
    color: var(--ink);
}

.world-meta strong {
    font-size: calc(13 * var(--ui-px));
    font-weight: 800;
    color: var(--muted);
}

.collection-screen-body {
    width: min(100%, calc(540 * var(--ui-px)));
    max-height: calc(100svh - 28 * var(--ui-px));
    overflow: hidden auto;
}

.collection-top {
    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: calc(12 * var(--ui-px));
    width: 100%;
    color: var(--muted);
    font-size: calc(13 * var(--ui-px));
    font-weight: 800;
    text-transform: uppercase;
}

.collection-grid {
    display: grid;
    grid-template-columns: repeat(auto-fill, minmax(calc(92 * var(--ui-px)), 1fr));
    gap: calc(10 * var(--ui-px));
    width: 100%;
}

.collection-card {
    display: grid;
    justify-items: center;
    gap: calc(4 * var(--ui-px));
    padding: calc(12 * var(--ui-px)) calc(8 * var(--ui-px));
    border: 1px solid var(--line);
    border-radius: calc(10 * var(--ui-px));
    background: rgb(255 250 240 / 70%);
    text-align: center;
}

.collection-card.is-found {
    border-color: var(--card-color);
    background:
        linear-gradient(180deg, rgb(255 255 255 / 60%), rgb(255 255 255 / 0%) 70%),
        color-mix(in srgb, var(--card-color) 24%, #fffaf0);
    box-shadow: 0 6px 14px rgb(36 31 25 / 10%);
}

.collection-icon {
    display: grid;
    place-items: center;
    width: calc(52 * var(--ui-px));
    height: calc(52 * var(--ui-px));
    border-radius: 50%;
    background: rgb(255 255 255 / 64%);
    box-shadow: inset 0 0 0 2px rgb(32 32 38 / 8%);
    font-size: calc(27 * var(--ui-px));
    filter: grayscale(1) opacity(0.4);
}

.collection-card.is-found .collection-icon {
    background: color-mix(in srgb, var(--card-color) 32%, #ffffff);
    box-shadow: inset 0 0 0 2px var(--card-color);
    filter: none;
}

.collection-name {
    color: var(--muted);
    font-size: calc(12 * var(--ui-px));
    font-weight: 800;
}

.collection-card.is-found .collection-name {
    color: var(--ink);
}

.game-over-panel-body {
    gap: calc(10 * var(--ui-px));
    border-top: calc(3 * var(--ui-px)) solid #c1503c;
}

.game-over-panel:not(.is-hidden) .game-over-panel-body {
    animation: game-over-pop 260ms cubic-bezier(0.2, 0.9, 0.3, 1.2);
}

.game-over-kicker {
    color: #c1503c;
    font-size: calc(12 * var(--ui-px));
    font-weight: 800;
    letter-spacing: 0.09em;
    text-transform: uppercase;
}

.game-over-panel-body strong {
    font-size: calc(30 * var(--ui-px));
    line-height: 1;
    text-align: center;
    color: var(--ink);
}

.game-over-sub {
    margin: 0;
    max-width: 28ch;
    color: var(--muted);
    font-size: calc(13 * var(--ui-px));
    font-weight: 600;
    line-height: 1.35;
    text-align: center;
}

/* Wraps as a row of whole buttons: a label that cannot fit beside its neighbour
   sends the button to its own line, rather than breaking the phrase across two
   lines inside the button. Matters most for the longer locale strings. */
.game-over-actions {
    display: flex;
    flex-wrap: wrap;
    justify-content: center;
    gap: calc(8 * var(--ui-px));
    margin-top: calc(2 * var(--ui-px));
}

.game-over-actions .icon-button {
    padding-inline: calc(16 * var(--ui-px));
    white-space: nowrap;
}

@keyframes game-over-pop {
    0% {
        transform: scale(0.9);
        opacity: 0;
    }

    100% {
        transform: scale(1);
        opacity: 1;
    }
}

/* Reset confirmation: mirrors the game-over dialog, tinted as a destructive step. */
.confirm-panel-body {
    gap: calc(10 * var(--ui-px));
    border-top: calc(3 * var(--ui-px)) solid #c1503c;
}

.confirm-panel:not(.is-hidden) .confirm-panel-body {
    animation: game-over-pop 260ms cubic-bezier(0.2, 0.9, 0.3, 1.2);
}

.confirm-kicker {
    color: #c1503c;
    font-size: calc(12 * var(--ui-px));
    font-weight: 800;
    letter-spacing: 0.09em;
    text-transform: uppercase;
}

.confirm-panel-body strong {
    font-size: calc(30 * var(--ui-px));
    line-height: 1;
    text-align: center;
    color: var(--ink);
}

.confirm-sub {
    margin: 0;
    max-width: 30ch;
    color: var(--muted);
    font-size: calc(13 * var(--ui-px));
    font-weight: 600;
    line-height: 1.35;
    text-align: center;
}

/* Wraps whole buttons rather than breaking a label across lines — see
   .game-over-actions, which this dialog mirrors. */
.confirm-actions {
    display: flex;
    flex-wrap: wrap;
    justify-content: center;
    gap: calc(8 * var(--ui-px));
    margin-top: calc(2 * var(--ui-px));
}

.confirm-actions .icon-button {
    padding-inline: calc(18 * var(--ui-px));
    white-space: nowrap;
}

@media (prefers-reduced-motion: reduce) {
    .game-over-panel:not(.is-hidden) .game-over-panel-body,
    .confirm-panel:not(.is-hidden) .confirm-panel-body {
        animation: none;
    }
}

@keyframes invalid {
    0%,
    100% {
        transform: translateX(0);
    }

    25% {
        transform: translateX(-4px);
    }

    75% {
        transform: translateX(4px);
    }
}

@keyframes tile-swap {
    0% {
        transform: translate(0, 0);
    }

    100% {
        transform: translate(var(--swap-x, 0), var(--swap-y, 0));
    }
}

@keyframes tile-swap-reject {
    0%,
    100% {
        transform: translate(0, 0);
    }

    48% {
        transform: translate(var(--swap-x, 0), var(--swap-y, 0));
    }
}

@keyframes tile-destroy {
    0% {
        opacity: 1;
        transform: scale(1);
    }

    100% {
        opacity: 0;
        transform: scale(0.58);
    }
}

@keyframes tile-hit {
    0%,
    100% {
        transform: scale(1);
    }

    45% {
        transform: scale(0.93);
    }
}

@keyframes tile-hit-flash {
    0%,
    100% {
        opacity: 0;
    }

    45% {
        opacity: 0.16;
    }
}

@keyframes score-popup {
    0% {
        opacity: 0;
        transform: translate(-50%, -38%) scale(0.82);
    }

    18% {
        opacity: 1;
        transform: translate(-50%, -54%) scale(1.08);
    }

    76% {
        opacity: 1;
        transform: translate(-50%, -88%) scale(1);
    }

    100% {
        opacity: 0;
        transform: translate(-50%, -112%) scale(0.96);
    }
}


/* ── CARGO ───────────────────────────────────────────────────────────────────
   A thing being moved INTO the room rather than cleared out of it (see
   CARGO_KINDS in tile.js): a pane of glass on its way down to the window. It is
   not a tile and must never be mistaken for one, so it is drawn as an object —
   translucent, edged, with a diagonal highlight running across it — rather than
   as a filled colour square. The pane is all CSS; a kind with painted art (the
   pillow) drops the pane's look, see .tile-cargo-painted below. */
.tile-cargo {
    background: linear-gradient(
        150deg,
        color-mix(in srgb, var(--tile-color) 55%, #ffffff) 0%,
        color-mix(in srgb, var(--tile-color) 30%, #ffffff) 46%,
        color-mix(in srgb, var(--tile-color) 68%, #7fb6c8) 100%
    );
    border: calc(2 * var(--ui-px)) solid color-mix(in srgb, var(--tile-color) 70%, #4a7c8c);
    box-shadow:
        inset 0 0 0 calc(2 * var(--ui-px)) rgb(255 255 255 / 45%),
        0 6px 10px rgb(43 35 28 / 22%);
    cursor: default;
}

/* The glint. A slash of light across the pane, which is what actually reads as
   "glass" — the tint alone reads as just another blue tile. */
.tile-cargo::after {
    content: "";
    position: absolute;
    inset: calc(6 * var(--ui-px));
    border-radius: calc(4 * var(--ui-px));
    background: linear-gradient(
        118deg,
        transparent 0%,
        transparent 34%,
        rgb(255 255 255 / 78%) 40%,
        rgb(255 255 255 / 78%) 47%,
        transparent 53%,
        transparent 68%,
        rgb(255 255 255 / 42%) 73%,
        rgb(255 255 255 / 42%) 77%,
        transparent 83%
    );
    pointer-events: none;
}

/* Painted cargo (CARGO_KINDS.image — the pillow) is its picture on the plain
   slot, like any other painted tile: none of the glass's edge or glint. It
   plops from the bottom of the pillow, not the middle of its box (the art's
   bottom edge sits at about 77% of the canvas), so it squashes onto what it
   landed on. */
.tile-cargo.tile-cargo-painted {
    border: 0;
}

.tile-cargo.tile-cargo-painted::after {
    content: none;
}

.tile-art-cargo {
    /* Bigger than the 122% of .tile-art: the pillow fills only two thirds of
       its canvas, and at 122% it read smaller than the tools around it. */
    width: 140%;
    height: 140%;
    transform-origin: 50% 77%;
}

/* The sill: the lowest square of each column on a delivery level (getSillRow),
   drawn as the ledge the cargo is coming down to. Only the underside is
   painted, so the row still reads as playable squares sitting ON something
   rather than as a wall. The room skin keeps its slot face and gets the ledge
   under it. */
.tile-sill {
    box-shadow:
        0 4px 6px rgb(43 35 28 / 16%),
        0 calc(5 * var(--ui-px)) 0 calc(-1 * var(--ui-px)) #a97f4e,
        0 calc(8 * var(--ui-px)) 0 calc(-1 * var(--ui-px)) #8a6234;
}

body.ts-room .tile.tile-sill {
    box-shadow:
        inset 0 1px 0 rgb(255 255 255 / 26%),
        inset 0 -2px 0 rgb(38 48 88 / 28%),
        0 calc(5 * var(--ui-px)) 0 calc(-1 * var(--ui-px)) #a97f4e,
        0 calc(8 * var(--ui-px)) 0 calc(-1 * var(--ui-px)) #8a6234;
}

/* The badge icon and the copy that flies to it: the same pane, minus the board
   cell's rounding. */
.goal-icon-cargo,
.goal-fly-cargo {
    width: 100%;
    height: 100%;
    border-radius: calc(3 * var(--ui-px));
    background: linear-gradient(150deg, #d8eef5 0%, #eef8fb 46%, #9fd8e8 100%);
    border: calc(2 * var(--ui-px)) solid #5f93a4;
    box-shadow: inset 0 0 0 calc(2 * var(--ui-px)) rgb(255 255 255 / 55%);
}

/* A painted kind's badge is its picture (renderCargoGoalIcon sets it). */
.goal-icon-cargo.goal-icon-cargo-painted {
    border: 0;
    border-radius: 0;
    box-shadow: none;
    background-color: transparent;
    background-position: center;
    background-repeat: no-repeat;
    background-size: 128%;
}

/* ── FLOOR ───────────────────────────────────────────────────────────────────
   The level lays a floor instead of only clearing one (LevelConfig.floorCells).
   A marked square is drawn in the GAP around its tile, not on it: an outer
   box-shadow paints outside the border box and under the tile's own background,
   so the tile stays exactly as readable as every other tile and the floor state
   lives in the space between them. The board's gap is 5 --ui-px, so a 3-unit
   ring on two neighbours meets in the middle and the laid squares run together
   into one continuous floor — which is the whole point: the pattern fills in. */
.tile-floor-open {
    box-shadow:
        0 0 0 calc(3 * var(--ui-px)) #6b5a48,
        0 4px 6px rgb(43 35 28 / 16%);
}

/* Laid parquet. The herringbone is HERE, in the texture of a finished square,
   rather than in which squares the level marks — a pattern of scattered single
   cells turned out to be the worst possible shape for a goal a straight 3-match
   has to land on (see dev/parquet in levels-dev.js). Two repeating gradients at
   opposing angles, which at this size is exactly what herringbone looks like. */
.tile-floor-laid {
    box-shadow:
        0 0 0 calc(3 * var(--ui-px)) #c08a4a,
        0 4px 6px rgb(43 35 28 / 16%);
}

/* Only the ring is textured, so the tile's own face stays untouched: the box is
   grown by the ring's width and its middle is masked out (padding-box XOR
   border-box — the standard way to get a gradient into what is really a border).
   Without the mask this would paint straight over the tile's background, since
   .tile is transformed and so a negative z-index child of it still draws above
   the element's own background. */
.tile-floor-laid::before {
    content: "";
    position: absolute;
    inset: calc(-3 * var(--ui-px));
    padding: calc(3 * var(--ui-px));
    border-radius: calc(14 * var(--ui-px));
    background:
        repeating-linear-gradient(52deg, #a9733c 0 calc(2 * var(--ui-px)), transparent 0 calc(6 * var(--ui-px))),
        repeating-linear-gradient(-52deg, #a9733c 0 calc(2 * var(--ui-px)), transparent 0 calc(6 * var(--ui-px)));
    mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
    mask-composite: exclude;
    -webkit-mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
    -webkit-mask-composite: xor;
    pointer-events: none;
}

/* The room skin draws every tile on a slot face, which covers the gap the ring
   above is painted in — there it read as a faint dotted outline. So here the
   laid square IS the floor: the slot face turns to new boards, three planks to
   a square, and the board fills in with wood as the level is played. */
body.ts-room .tile.tile-floor-laid {
    background:
        linear-gradient(180deg, rgb(255 255 255 / 20%), transparent 45%),
        repeating-linear-gradient(
            180deg,
            transparent 0 31%,
            rgb(96 58 26 / 55%) 31% 33.34%
        ),
        linear-gradient(180deg, #d9a56a, #b97f45);
    box-shadow:
        inset 0 1px 0 rgb(255 255 255 / 30%),
        inset 0 -2px 0 rgb(80 48 20 / 40%);
}

body.ts-room .tile.tile-floor-laid::before {
    content: none;
}

/* The badge icon: two blocks at opposing angles, which is what herringbone is. */
.goal-icon-floor {
    position: relative;
    width: 78%;
    height: 78%;
    border-radius: calc(2 * var(--ui-px));
    background: #c08a4a;
    overflow: hidden;
}

.goal-icon-floor::before,
.goal-icon-floor::after {
    content: "";
    position: absolute;
    inset: 12% auto 12% 14%;
    width: 26%;
    border-radius: calc(1 * var(--ui-px));
    background: #8a6234;
    transform: rotate(38deg);
}

.goal-icon-floor::after {
    inset: 12% 14% 12% auto;
    transform: rotate(-38deg);
}

/* ── SWEEP ───────────────────────────────────────────────────────────────────
   A charged tool (SWEEP_OFFSET in tile.js): tap it and every tile of its kind
   goes with it. It keeps the ordinary art of its colour on purpose — a charged
   hammer has to still read as a hammer — so the charge is carried entirely by
   what is around it: a bright ring in the tile's own colour, and a slow pulse
   that marks it as the one thing on the board waiting to be pressed. */
.tile-sweep {
    cursor: pointer;
    box-shadow:
        inset 0 0 0 calc(3 * var(--ui-px)) rgb(255 255 255 / 78%),
        0 0 0 calc(2 * var(--ui-px)) var(--tile-color),
        0 0 calc(14 * var(--ui-px)) calc(2 * var(--ui-px)) color-mix(in srgb, var(--tile-color) 60%, transparent),
        0 4px 6px rgb(43 35 28 / 16%);
    animation: sweep-charge 1500ms ease-in-out infinite;
}

@keyframes sweep-charge {
    0%, 100% { filter: brightness(1); }
    50% { filter: brightness(1.16); }
}

@media (prefers-reduced-motion: reduce) {
    .tile-sweep {
        animation: none;
    }
}

/* Dev overlay (toggleDevOverlay): testing controls, never seen in normal play.
   Deliberately plain and dark so it is never mistaken for game UI. */
/* A column just right of the left HUD — the landscape rail (--rail-width), or in
   portrait the .corner-stats cluster (124 reference px) — over the empty wall,
   clear of the board, the magazine beside it and the dialogue box along the
   bottom. */
.dev-overlay {
    position: fixed;
    top: 50%;
    left: calc(max(calc(12 * var(--ui-px)), env(safe-area-inset-left)) + var(--rail-width, 124 * var(--ui-px)) + 40px);
    z-index: 99999;
    display: flex;
    flex-direction: column;
    gap: 6px;
    padding: 6px;
    border-radius: 12px;
    background: rgb(20 22 26 / 82%);
    box-shadow: 0 6px 18px rgb(0 0 0 / 40%);
    transform: translateY(-50%);
    touch-action: manipulation;
}

.dev-overlay-btn {
    padding: 10px 14px;
    border: 1px solid rgb(255 255 255 / 18%);
    border-radius: 8px;
    background: rgb(255 255 255 / 8%);
    color: #fff;
    font: 700 14px sans-serif;
    white-space: nowrap;
    cursor: pointer;
}

.dev-overlay-btn:active {
    background: rgb(255 255 255 / 22%);
}
