:root {
    --color-bg: #10131a;
    --color-surface: #1a1f2b;
    --color-surface-raised: #232a3a;
    --color-text: #f2f4f8;
    --color-text-muted: #9aa4b8;
    --color-primary: #3ec1ff;
    --color-primary-dark: #1b8fca;
    --color-accent: #ff5f8f;
    --color-border: #2e3648;
    --space-1: 0.25rem;
    --space-2: 0.5rem;
    --space-3: 1rem;
    --space-4: 1.5rem;
    --space-5: 2.5rem;
    --radius: 0.75rem;
    /* The two strips the play screen spends on chrome. They are declared here rather than
       left to the content because the board sizes itself from what is left over. */
    --hud-height: 2.75rem;
    --tray-height: 4.25rem;
    /* The biggest a bubble may be drawn before the sprite art starts being scaled up. The
       sliced bubbles are 128px square (scripts/slice-bubble-sprites.py), so the ceiling is
       those 128 device pixels expressed back in CSS pixels at the screen's own ratio. The
       bands below step it down as the ratio climbs; this first value is the 1x case.

       This is a quality ceiling, not a layout choice. The board sizes itself to the screen
       (see .game-board-wrapper) and on a phone that fit is what binds -- a 12-column board
       wants a 30px cell on a 400px-wide screen and never comes near any of these numbers.
       They only bite on a tablet or a desktop window, where the fit alone would keep growing
       the cell until the art gave out. */
    --cell-max: 128px;
}

/* Common device ratios, each capped at the CSS size that still draws the 128px art 1:1 or
   smaller. Anything between two bands takes the lower one, which errs towards sharpness. */
@media (min-resolution: 1.25dppx) {
    :root { --cell-max: 102px; }
}

@media (min-resolution: 1.5dppx) {
    :root { --cell-max: 85px; }
}

@media (min-resolution: 2dppx) {
    :root { --cell-max: 64px; }
}

@media (min-resolution: 2.5dppx) {
    :root { --cell-max: 51px; }
}

@media (min-resolution: 3dppx) {
    :root { --cell-max: 42px; }
}

* {
    box-sizing: border-box;
}

html, body {
    height: 100%;
    margin: 0;
    font-family: 'Segoe UI', 'Helvetica Neue', Helvetica, Arial, sans-serif;
    background: var(--color-bg);
    color: var(--color-text);
}

h1:focus {
    outline: none;
}

a {
    color: var(--color-primary);
}

.app-shell {
    display: flex;
    flex-direction: column;
    min-height: 100vh;
    /* Installed as a PWA the page runs edge to edge, so hold the UI clear of the status bar,
       the navigation bar and any display cutout. Inside the MAUI shell every one of these is
       0: MainActivity has already padded the WebView out of the system bars. */
    padding: env(safe-area-inset-top) env(safe-area-inset-right) env(safe-area-inset-bottom) env(safe-area-inset-left);
}

.app-content {
    flex: 1;
    display: flex;
    flex-direction: column;
}

/* Top bar: on the menu screens it is the menu bubble and nothing else. The play screen
   doesn't render it at all - its HUD carries the same bubble, so there is only ever one bar
   between the top of the screen and the board. */
.top-bar {
    display: flex;
    align-items: center;
    padding: var(--space-2) var(--space-3);
}

/* The rainbow bubble that opens the menu. It is the game's own rainbow special, so the
   control the player reaches for most is drawn from the same art as the board. */
.game-menu {
    position: relative;
    display: flex;
    align-items: center;
}

.game-menu__orb {
    /* 44px: the smallest comfortable touch target, and the thing that sets the bar's height. */
    width: 2.75rem;
    height: 2.75rem;
    padding: 0;
    border: none;
    border-radius: 50%;
    background-color: transparent;
    background-size: 100% 100%;
    cursor: pointer;
    transition: transform 0.1s ease-in-out;
}

.game-menu__orb:hover {
    transform: scale(1.06);
}

.game-menu__orb:active {
    transform: scale(0.94);
}

.game-menu__orb:focus-visible {
    outline: 2px solid var(--color-primary);
    outline-offset: 2px;
}

/* Covers the whole screen so the tap that dismisses the menu cannot also reach the board
   underneath and fire a shot. */
.game-menu__scrim {
    position: fixed;
    inset: 0;
    z-index: 20;
}

.game-menu__panel {
    position: absolute;
    top: calc(100% + var(--space-2));
    left: 0;
    z-index: 21;
    display: flex;
    flex-direction: column;
    min-width: 10rem;
    padding: var(--space-1);
    background: var(--color-surface-raised);
    border: 1px solid var(--color-border);
    border-radius: var(--radius);
    box-shadow: 0 8px 24px rgba(0, 0, 0, 0.45);
}

.game-menu__item {
    display: block;
    width: 100%;
    padding: var(--space-2) var(--space-3);
    font-size: 1rem;
    font-weight: 600;
    text-align: left;
    text-decoration: none;
    color: var(--color-text);
    background: transparent;
    border: none;
    border-radius: var(--radius);
    cursor: pointer;
}

.game-menu__item:hover {
    background: var(--color-surface);
}

.game-menu__item.active {
    color: var(--color-primary);
}

/* Buttons */
.btn {
    font-size: 1rem;
    font-weight: 600;
    padding: var(--space-2) var(--space-4);
    border-radius: var(--radius);
    border: 1px solid transparent;
    cursor: pointer;
    transition: transform 0.05s ease-in-out, background-color 0.15s ease-in-out;
}

.btn:active {
    transform: scale(0.97);
}

.btn:focus-visible {
    outline: 2px solid var(--color-primary);
    outline-offset: 2px;
}

.btn-primary {
    color: #06202b;
    background-color: var(--color-primary);
}

.btn-primary:hover {
    background-color: var(--color-primary-dark);
    color: #fff;
}

.btn-secondary {
    color: var(--color-text);
    background-color: var(--color-surface-raised);
    border-color: var(--color-border);
}

.btn-secondary:hover {
    border-color: var(--color-primary);
}

/* Menu screen (main menu, settings, not-found) */
.menu-screen {
    flex: 1;
    display: flex;
    flex-direction: column;
    align-items: center;
    justify-content: center;
    text-align: center;
    gap: var(--space-3);
    padding: var(--space-5) var(--space-3);
}

.game-title {
    font-size: clamp(2rem, 6vw, 3.5rem);
    margin: 0;
    background: linear-gradient(135deg, var(--color-primary), var(--color-accent));
    -webkit-background-clip: text;
    background-clip: text;
    color: transparent;
}

.game-subtitle {
    color: var(--color-text-muted);
    margin: 0 0 var(--space-3) 0;
}

.game-stats {
    color: var(--color-text-muted);
    font-size: 0.875rem;
    margin: 0 0 var(--space-2) 0;
}

.menu-actions {
    display: flex;
    flex-direction: column;
    gap: var(--space-2);
    width: 100%;
    max-width: 20rem;
}

.settings-list {
    display: flex;
    flex-direction: column;
    gap: var(--space-2);
    width: 100%;
    max-width: 24rem;
    background: var(--color-surface);
    border: 1px solid var(--color-border);
    border-radius: var(--radius);
    padding: var(--space-3);
}

.settings-row {
    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: var(--space-3);
    padding: var(--space-2) 0;
}

.settings-row input[type="range"] {
    flex: 1;
    max-width: 12rem;
    accent-color: var(--color-primary);
}

/* Ammunition bar: pick a plain bubble or one of the mega weapons for the next shot. */
.weapon-bar {
    user-select: none;
    -webkit-user-select: none;
    display: flex;
    align-items: center;
    justify-content: center;
    /* Never wraps: a second row would come out of the board's height, and the whole tray is
       four buttons that fit a 320px screen. */
    flex-wrap: nowrap;
    min-height: var(--tray-height);
    gap: var(--space-2);
    padding: var(--space-2) var(--space-2) var(--space-3);
    background: var(--color-surface);
    border-top: 1px solid var(--color-border);
}

.weapon-option {
    display: flex;
    align-items: center;
    gap: var(--space-1);
    /* Icon and count only, but still a full 44px touch target: the saving is horizontal,
       which is what kept the bar from fitting on one row on a phone. */
    min-height: 2.75rem;
    padding: var(--space-1) var(--space-2);
    font-size: 0.875rem;
    font-weight: 600;
    color: var(--color-text);
    background: transparent;
    border: 1px solid var(--color-border);
    border-radius: var(--radius);
    cursor: pointer;
    transition: transform 0.05s ease-in-out, border-color 0.15s ease-in-out, background-color 0.15s ease-in-out;
}

.weapon-option:hover:not(:disabled) {
    border-color: var(--color-primary);
}

.weapon-option:active:not(:disabled) {
    transform: scale(0.97);
}

.weapon-option:disabled {
    opacity: 0.4;
    cursor: default;
}

.weapon-option.active {
    border-color: var(--color-primary);
    background: var(--color-surface-raised);
    box-shadow: 0 0 0 1px var(--color-primary) inset;
}

.weapon-icon {
    position: relative;
    width: 1.75rem;
    height: 1.75rem;
    border-radius: 50%;
    flex-shrink: 0;
    /* The weapon art sizes itself in percentages, but its glows and shadows are in em. Holding
       the icon's font-size to the same fraction of its width that a bubble's is of a cell keeps
       those effects in proportion instead of swamping an icon this small. */
    font-size: 0.4rem;
}

.weapon-count {
    min-width: 1.25rem;
    padding: 0 var(--space-1);
    border-radius: var(--radius);
    background: var(--color-surface-raised);
    color: var(--color-text-muted);
    text-align: center;
}

.weapon-option.active .weapon-count {
    color: var(--color-text);
}

/* Gameplay screen */
.game-screen {
    flex: 1;
    display: flex;
    flex-direction: column;
}

.menu-note {
    max-width: 34rem;
    margin: var(--space-3) auto 0;
    color: var(--color-text-muted);
    font-size: 0.9rem;
    line-height: 1.5;
    text-align: center;
}

/* The play screen's only bar. Everything on it is on one row at 320px and up, which is what
   the stacked value-over-label stats buy: "1234" over "SCORE" is half the width of
   "Score: 1234", so five counters and the menu bubble still fit a 360px phone. */
.game-hud {
    display: flex;
    align-items: center;
    flex-wrap: nowrap;
    min-height: var(--hud-height);
    gap: var(--space-3);
    padding: 0 var(--space-3);
    background: var(--color-surface);
    border-bottom: 1px solid var(--color-border);
}

.hud-stats {
    display: flex;
    align-items: center;
    justify-content: space-evenly;
    flex: 1;
    /* Lets the row squeeze rather than overflow if a mode ever adds a sixth counter. */
    min-width: 0;
    gap: var(--space-2);
    overflow: hidden;
}

.hud-stat {
    display: flex;
    flex-direction: column;
    align-items: center;
    line-height: 1.1;
    white-space: nowrap;
}

.hud-stat b {
    font-size: 1rem;
    font-weight: 700;
}

.hud-stat small,
.hud-next small {
    font-size: 0.6rem;
    font-weight: 600;
    letter-spacing: 0.06em;
    text-transform: uppercase;
    color: var(--color-text-muted);
}

/* The two counters that can end a run. They go red on the shot before it happens, so the
   warning arrives while the player can still do something about it. */
.hud-stat.low b {
    color: #ff6b6b;
}

.hud-next {
    display: flex;
    flex-direction: column;
    align-items: center;
    gap: 0.1rem;
    line-height: 1.1;
}

.next-swatch {
    display: block;
    width: 1.15rem;
    height: 1.15rem;
    border-radius: 50%;
    background-size: 100% 100%;
}

.overlay {
    position: fixed;
    inset: 0;
    display: flex;
    align-items: center;
    justify-content: center;
    background: rgba(8, 10, 15, 0.75);
    z-index: 10;
}

.overlay-panel {
    background: var(--color-surface);
    border: 1px solid var(--color-border);
    border-radius: var(--radius);
    padding: var(--space-5) var(--space-4);
    text-align: center;
    min-width: 16rem;
}

.overlay-panel h2 {
    margin: 0 0 var(--space-2) 0;
    color: var(--color-primary);
}

.overlay-actions {
    display: flex;
    gap: var(--space-2);
    justify-content: center;
    margin-top: var(--space-3);
    flex-wrap: wrap;
}

.play-area {
    position: relative;
    flex-shrink: 0;
    touch-action: none;
    cursor: crosshair;
    /* Dragging to aim would otherwise select the board as text, and a second drag starting
       inside that selection begins a native drag -- which cancels the pointer and swallows
       the shot. On a phone it also raises the text-selection magnifier mid-aim. */
    user-select: none;
    -webkit-user-select: none;
}

.play-area:focus-visible {
    outline: 2px solid var(--color-primary);
    outline-offset: 2px;
}

.trajectory-overlay {
    position: absolute;
    inset: 0;
    width: 100%;
    height: 100%;
    pointer-events: none;
}

.trajectory-overlay polyline {
    fill: none;
    stroke: rgba(242, 244, 248, 0.5);
    stroke-width: 2px;
    stroke-dasharray: 6px 5px;
    vector-effect: non-scaling-stroke;
}

/* The play area is a fixed grid of cells, so the only way to fit it to a screen is to size
   the cell. Play hands in how many cells across and down it is; the cell is then the largest
   of those that fits the width left over, the height left over once the bar and the weapon
   tray have taken theirs, and --cell-max, the point past which the sprite art would be
   scaled up rather than down.

   All three terms matter, on different screens. On a phone the width binds: a 12-column
   board is 375px across at a 30px cell, which already overflows a 360px handset. On a tall
   phone the height binds instead. On a tablet or a desktop window both are slack -- an
   800px-wide tablet fits a 61px cell -- and the board grows into the room rather than
   sitting as a phone-sized island in the middle of the screen. --cell-max is what stops that
   growth at the resolution the bubbles were cut at. */
.game-board-wrapper {
    --chrome-height: calc(var(--hud-height) + var(--tray-height) + var(--space-3) * 2);
    --cell-size: min(
        var(--cell-max),
        calc((100vw - var(--space-3) * 2) / var(--board-columns, 12.5)),
        calc((100vh - var(--chrome-height)) / var(--board-rows, 16)));
    flex: 1;
    display: flex;
    /* The play area is a fixed number of cells both ways, so once the cell has been shrunk
       to fit the width there is height left over on a tall phone that no board can use.
       Centring it frames the field instead of stranding the shooter above a dead strip. */
    align-items: center;
    justify-content: center;
    padding: var(--space-3);
    overflow: auto;
}

/* svh is the viewport with the mobile browser's bars showing, so the board is sized for the
   screen the player actually has rather than the one they get after scrolling. */
@supports (height: 100svh) {
    .game-board-wrapper {
        --cell-size: min(
            var(--cell-max),
            calc((100vw - var(--space-3) * 2) / var(--board-columns, 12.5)),
            calc((100svh - var(--chrome-height)) / var(--board-rows, 16)));
    }
}

.game-board {
    position: relative;
    flex-shrink: 0;
}

.bubble {
    position: absolute;
    width: var(--cell-size);
    height: var(--cell-size);
    border-radius: 50%;
    transform: translate(-50%, -50%);
    transition: transform 0.3s ease-in, opacity 0.3s ease-in;
}

/* The mega weapons are still drawn with gradients rather than sprite art, so they are the
   only bubbles that need the moulded highlight painting on. */
.bubble-bullet, .bubble-laser, .bubble-mega-diamond-laser {
    box-shadow: inset -0.15em -0.15em 0.3em rgba(0, 0, 0, 0.25), inset 0.1em 0.1em 0.2em rgba(255, 255, 255, 0.4);
}

.bubble.removing {
    transform: translate(-50%, -50%) scale(0);
    opacity: 0;
}

/* Each mega weapon leaves its own mark on what it destroys. */
.bubble.removing-bullet {
    transition: transform 0.3s ease-in, opacity 0.3s ease-in;
    transform: translate(-50%, -50%) scale(0.2) rotate(160deg);
    opacity: 0;
}

.bubble.removing-laser {
    transition: transform 0.3s ease-out, opacity 0.3s ease-out, filter 0.3s ease-out;
    transform: translate(-50%, -50%) scaleX(0.15) scaleY(1.6);
    filter: brightness(4) saturate(0);
    opacity: 0;
}

.bubble.removing-blast {
    transition: transform 0.15s ease-out, opacity 0.15s ease-out, filter 0.15s ease-out;
    transform: translate(-50%, -50%) scale(1.6);
    filter: brightness(3);
    opacity: 0;
}

.bubble.falling {
    transition: transform 0.5s cubic-bezier(0.55, 0, 1, 0.45), opacity 0.5s ease-in;
    transform: translate(-50%, -50%) translateY(4em) rotate(45deg);
    opacity: 0;
}

/* Destruction debris. Positions come from the game loop in board units, so these rules only
   describe how a particle looks -- never where it is or how it moves. */
.fx {
    position: absolute;
    pointer-events: none;
    will-change: transform;
}

.fx.fx-droplet {
    border-radius: 50%;
    background-image: none;
    background-color: var(--bubble-tint, #ffffff);
    box-shadow: inset -0.1em -0.1em 0.2em rgba(0, 0, 0, 0.3), 0 0 0.2em rgba(255, 255, 255, 0.25);
}

.fx-spark {
    border-radius: 40% 40% 40% 40% / 50% 50% 50% 50%;
    background: linear-gradient(90deg, #fff8dc, #ffb340);
    box-shadow: 0 0 0.4em 0.05em rgba(255, 196, 84, 0.8);
}

.fx-spark.fx-laser {
    background: linear-gradient(90deg, #ffffff, #ff5a4d);
    box-shadow: 0 0 0.4em 0.05em rgba(255, 90, 77, 0.85);
}

/* A whole bubble blown clear of the board by the mega diamond laser. */
.fx-debris {
    border-radius: 50%;
}

/* The detonation ring: the element grows via its transform, so only the ring itself is here. */
.fx-shockwave {
    border-radius: 50%;
    border: 0.12em solid rgba(244, 232, 255, 0.9);
    background: radial-gradient(circle, rgba(196, 181, 253, 0.55) 0%, rgba(139, 92, 246, 0.25) 55%, rgba(46, 16, 101, 0) 72%);
    box-shadow: 0 0 1.2em 0.3em rgba(168, 85, 247, 0.7);
}

/* The laser's strike, held on screen for a moment after it fires. */
.beam-overlay {
    position: absolute;
    inset: 0;
    width: 100%;
    height: 100%;
    pointer-events: none;
    z-index: 2;
}

.beam-overlay polyline {
    fill: none;
    stroke-linecap: round;
    stroke-linejoin: round;
    vector-effect: non-scaling-stroke;
}

.beam-overlay .beam-glow {
    stroke: rgba(255, 59, 48, 0.55);
    stroke-width: 18px;
}

.beam-overlay .beam-core {
    stroke: #fff3e0;
    stroke-width: 5px;
}

.score-popup {
    position: absolute;
    transform: translate(-50%, -50%);
    color: var(--color-primary);
    font-weight: 700;
    font-size: 1rem;
    pointer-events: none;
    text-shadow: 0 1px 3px rgba(0, 0, 0, 0.6);
    animation: score-popup-float 1.2s ease-out forwards;
}

@keyframes score-popup-float {
    0% {
        transform: translate(-50%, -50%) translateY(0);
        opacity: 1;
    }
    100% {
        transform: translate(-50%, -50%) translateY(-2em);
        opacity: 0;
    }
}

/* Bubble faces. The art comes from sprites/bubble-blaster-sprites.png, sliced into one file
   per bubble by scripts/slice-bubble-sprites.py, so a bubble is an image rather than a shaded
   circle. --bubble-tint carries the same colour as a flat value, for the pop droplets thrown
   by a bubble of that colour. */
.bubble-red, .bubble-orange, .bubble-yellow, .bubble-green, .bubble-cyan,
.bubble-blue, .bubble-purple, .bubble-pink, .bubble-white, .bubble-black,
.bubble-special {
    background-repeat: no-repeat;
    background-position: center;
    background-size: 100% 100%;
}

.bubble-red { --bubble-tint: #f92121; background-image: url(../assets/images/bubbles/red.png); }
.bubble-orange { --bubble-tint: #fb6f03; background-image: url(../assets/images/bubbles/orange.png); }
.bubble-yellow { --bubble-tint: #f5c802; background-image: url(../assets/images/bubbles/yellow.png); }
.bubble-green { --bubble-tint: #2fc421; background-image: url(../assets/images/bubbles/green.png); }
.bubble-cyan { --bubble-tint: #00c9f4; background-image: url(../assets/images/bubbles/cyan.png); }
.bubble-blue { --bubble-tint: #012cf5; background-image: url(../assets/images/bubbles/blue.png); }
.bubble-purple { --bubble-tint: #8a04ea; background-image: url(../assets/images/bubbles/purple.png); }
.bubble-pink { --bubble-tint: #f92982; background-image: url(../assets/images/bubbles/pink.png); }
.bubble-white { --bubble-tint: #e5e2e0; background-image: url(../assets/images/bubbles/white.png); }
.bubble-black { --bubble-tint: #1b1a1a; background-image: url(../assets/images/bubbles/black.png); }

/* Special bubbles (specification.md section 9). The faint halo marks them out as something
   other than a colour to match, at a glance and at phone size. */
.bubble-special {
    filter: drop-shadow(0 0 0.12em rgba(255, 255, 255, 0.55));
}

/* Obstacles (specification.md section 21.3). There is no sheet art for these, so they are
   drawn here -- flat and hard-edged on purpose, so nothing about them reads as a bubble
   waiting to be matched. */
.bubble-obstacle {
    box-shadow: inset -0.12em -0.12em 0.25em rgba(0, 0, 0, 0.45), inset 0.1em 0.1em 0.2em rgba(255, 255, 255, 0.15);
}

/* Stone is warm and rough-cornered; iron is cold, square and riveted. The two have to be told
   apart at a glance, because only one of them can be broken. */
.bubble-stone {
    border-radius: 22%;
    background: linear-gradient(150deg, #9a8f83 0%, #75695d 45%, #52483f 100%);
}

.bubble-iron {
    border-radius: 8%;
    background:
        radial-gradient(circle at 22% 22%, rgba(255, 255, 255, 0.85) 0 8%, transparent 9%),
        radial-gradient(circle at 78% 22%, rgba(255, 255, 255, 0.85) 0 8%, transparent 9%),
        radial-gradient(circle at 22% 78%, rgba(255, 255, 255, 0.85) 0 8%, transparent 9%),
        radial-gradient(circle at 78% 78%, rgba(255, 255, 255, 0.85) 0 8%, transparent 9%),
        linear-gradient(150deg, #b9c6d6 0%, #6f8095 45%, #36414f 100%);
    outline: 0.12em solid rgba(228, 238, 250, 0.55);
    outline-offset: -0.12em;
}

/* The spikes themselves: eight points around a small dark core, drawn as a conic gradient so
   the shape costs nothing at 60fps. */
.bubble-spike {
    background:
        conic-gradient(from 22.5deg,
            #e8534d 0deg 22.5deg, transparent 22.5deg 45deg,
            #e8534d 45deg 67.5deg, transparent 67.5deg 90deg,
            #e8534d 90deg 112.5deg, transparent 112.5deg 135deg,
            #e8534d 135deg 157.5deg, transparent 157.5deg 180deg,
            #e8534d 180deg 202.5deg, transparent 202.5deg 225deg,
            #e8534d 225deg 247.5deg, transparent 247.5deg 270deg,
            #e8534d 270deg 292.5deg, transparent 292.5deg 315deg,
            #e8534d 315deg 337.5deg, transparent 337.5deg 360deg),
        radial-gradient(circle at 50% 50%, #3a1412 0 42%, transparent 42%);
    box-shadow: none;
}

.bubble-blackhole {
    background: radial-gradient(circle at 50% 50%, #000 0 38%, #2b1b4d 62%, #6b3fa0 82%, transparent 96%);
    box-shadow: 0 0 0.35em rgba(139, 92, 246, 0.7);
}

.bubble-colorswitcher {
    background: conic-gradient(#f92121, #fb6f03, #f5c802, #2fc421, #00c9f4, #012cf5, #8a04ea, #f92982, #f92121);
    outline: 0.08em solid rgba(255, 255, 255, 0.7);
    outline-offset: -0.14em;
}

/* A captive keeps its own colour art; the cage goes over the top so the player can still read
   what it matches. */
.bubble-captive {
    box-shadow: inset 0 0 0 0.12em rgba(255, 214, 92, 0.95), 0 0 0.3em rgba(255, 214, 92, 0.6);
}

.bubble-rainbow { --bubble-tint: #ff2d95; background-image: url(../assets/images/bubbles/rainbow.png); }
.bubble-star { --bubble-tint: #f5c802; background-image: url(../assets/images/bubbles/star.png); }
.bubble-lightning { --bubble-tint: #4aa8ff; background-image: url(../assets/images/bubbles/lightning.png); }
.bubble-fire { --bubble-tint: #f98e00; background-image: url(../assets/images/bubbles/fire.png); }
.bubble-freeze { --bubble-tint: #7fd8f7; background-image: url(../assets/images/bubbles/freeze.png); }
.bubble-moon { --bubble-tint: #8a6bd8; background-image: url(../assets/images/bubbles/moon.png); }
.bubble-leaf { --bubble-tint: #2fc421; background-image: url(../assets/images/bubbles/leaf.png); }
.bubble-water { --bubble-tint: #0157d2; background-image: url(../assets/images/bubbles/water.png); }
.bubble-heart { --bubble-tint: #f92982; background-image: url(../assets/images/bubbles/heart.png); }
.bubble-honey { --bubble-tint: #d08c0b; background-image: url(../assets/images/bubbles/honey.png); }

.bubble-bullet {
    background: radial-gradient(circle at 35% 30%, #fff2b8, #d9a920 55%, #7a5c12 100%);
}

.bullet-arrow {
    position: absolute;
    top: 50%;
    left: 50%;
    width: 72%;
    height: 34%;
    clip-path: polygon(0% 25%, 55% 25%, 55% 4%, 100% 50%, 55% 96%, 55% 75%, 0% 75%);
    background: linear-gradient(90deg, #d4af37 0%, #f6e27a 35%, #c8791f 65%, #7a4419 100%);
    filter: drop-shadow(0 1px 1px rgba(0, 0, 0, 0.45));
    pointer-events: none;
}

.bubble-laser {
    background: radial-gradient(circle at 35% 30%, #565660, #232329 55%, #08080a 100%);
}

.laser-beam {
    position: absolute;
    top: 50%;
    left: 50%;
    width: 92%;
    height: 14%;
    background: linear-gradient(90deg, #ff3b30 0%, #ffe9c2 50%, #ff3b30 100%);
    box-shadow: 0 0 0.35em 0.05em rgba(255, 59, 48, 0.85);
    pointer-events: none;
}

.bubble-mega-diamond-laser {
    background: radial-gradient(circle at 35% 30%, #f3e8ff, #8b5cf6 50%, #2e1065 100%);
}

/* Fixed fan of beams: full horizontal line plus both upward diagonals -- this bubble's
   fire pattern never changes, so unlike bullet/laser these angles aren't computed per-instance. */
.mega-beam {
    position: absolute;
    top: 50%;
    left: 50%;
    width: 92%;
    height: 12%;
    background: linear-gradient(90deg, #ff6bd6 0%, #ffffff 50%, #7c3aed 100%);
    box-shadow: 0 0 0.35em 0.05em rgba(168, 85, 247, 0.85);
    pointer-events: none;
}

.mega-beam-horizontal { transform: translate(-50%, -50%) rotate(0deg); }
.mega-beam-upper-left { transform: translate(-50%, -50%) rotate(240deg); }
.mega-beam-upper-right { transform: translate(-50%, -50%) rotate(300deg); }

.mega-gem {
    position: absolute;
    top: 50%;
    left: 50%;
    width: 32%;
    height: 32%;
    background: linear-gradient(135deg, #ffffff 0%, #c4b5fd 45%, #6d28d9 100%);
    transform: translate(-50%, -50%) rotate(45deg);
    box-shadow: 0 0 0.3em rgba(255, 255, 255, 0.75);
    pointer-events: none;
}

/* ---------------------------------------------------------------------------------------
   Special bubbles (specification.md section 9). Everything here is look only: where each
   piece is and how it moves comes from the game loop, in board units.
   --------------------------------------------------------------------------------------- */

/* The special's own sprite, still on screen after the grid has let go of it. Its transform is
   rewritten every frame, so it must not be eased -- the effect is already doing the easing. */
.special-performer {
    z-index: 3;
    transition: none;
    pointer-events: none;
}

/* Orbs, bolts, leaves, droplets and bees. */
.special-agent {
    position: absolute;
    z-index: 3;
    pointer-events: none;
    will-change: transform;
}

.agent-orb {
    border-radius: 50%;
}

.agent-orb.agent-rainbow {
    background: conic-gradient(#ff3b30, #ffcc00, #4cd964, #00c9f4, #5856d6, #ff2d95, #ff3b30);
    box-shadow: 0 0 0.6em 0.15em rgba(255, 255, 255, 0.75);
}

.agent-orb.agent-star {
    background: radial-gradient(circle at 35% 30%, #fffbe6, #f5c802 55%, #b26400 100%);
    box-shadow: 0 0 0.6em 0.15em rgba(245, 200, 2, 0.9);
}

/* The bolt's head; the jagged line behind it is drawn as SVG in .bolt-overlay. */
.agent-bolt {
    border-radius: 50%;
    background: radial-gradient(circle, #ffffff 0%, #9fd8ff 45%, rgba(1, 44, 245, 0) 75%);
    box-shadow: 0 0 0.8em 0.2em rgba(120, 190, 255, 0.9);
}

.agent-leaf {
    border-radius: 0 100% 0 100%;
    background: linear-gradient(135deg, #7ede4e 0%, #2fc421 45%, #075902 100%);
    box-shadow: inset -0.05em -0.05em 0.15em rgba(0, 0, 0, 0.35);
}

.agent-droplet {
    /* Tapered at the top, rounded underneath: the shape a droplet falls in. */
    border-radius: 50% 50% 50% 50% / 35% 35% 65% 65%;
    background: linear-gradient(180deg, #cdeeff 0%, #0157d2 60%, #0134a0 100%);
    box-shadow: inset 0.08em 0.08em 0.15em rgba(255, 255, 255, 0.7), 0 0 0.3em rgba(1, 87, 210, 0.7);
}

.agent-bee {
    border-radius: 50%;
    background: repeating-linear-gradient(115deg, #1b1a1a 0 22%, #f5c802 22% 50%);
    box-shadow: 0 0 0.35em 0.12em rgba(255, 255, 255, 0.55);
}

/* The lightning bolt's traced path, drawn over the board the way the laser beam is. */
.bolt-overlay {
    position: absolute;
    inset: 0;
    width: 100%;
    height: 100%;
    pointer-events: none;
    z-index: 3;
}

.bolt-overlay polyline {
    fill: none;
    stroke-linecap: round;
    stroke-linejoin: round;
    vector-effect: non-scaling-stroke;
}

.bolt-glow {
    stroke: rgba(120, 190, 255, 0.75);
    stroke-width: 10;
}

.bolt-core {
    stroke: #ffffff;
    stroke-width: 3;
}

/* Burst debris: flame and smoke from a fire bubble, frost and snow from a freeze, cloud and
   bloom from the moon, hearts from the heart. */
.fx-flame {
    border-radius: 50% 50% 45% 45%;
    background: radial-gradient(circle at 50% 70%, #fff3b0 0%, #f98e00 40%, #c1240b 75%, rgba(124, 4, 0, 0) 100%);
    box-shadow: 0 0 0.6em 0.15em rgba(249, 142, 0, 0.8);
}

.fx-smoke {
    border-radius: 50%;
    background: radial-gradient(circle, rgba(120, 116, 112, 0.75) 0%, rgba(70, 68, 66, 0.35) 60%, rgba(40, 40, 40, 0) 100%);
}

.fx-vapor {
    border-radius: 50%;
    background: radial-gradient(circle, rgba(236, 247, 253, 0.8) 0%, rgba(167, 216, 245, 0.4) 55%, rgba(1, 105, 184, 0) 100%);
}

/* Six spokes: enough to read as a snowflake at the size these are drawn. */
.fx-snowflake {
    background:
        linear-gradient(0deg, transparent 44%, #ecf7fd 44% 56%, transparent 56%),
        linear-gradient(60deg, transparent 44%, #ecf7fd 44% 56%, transparent 56%),
        linear-gradient(120deg, transparent 44%, #ecf7fd 44% 56%, transparent 56%);
    filter: drop-shadow(0 0 0.15em rgba(199, 236, 250, 0.9));
}

.fx-cloud {
    border-radius: 50%;
    background: radial-gradient(circle at 40% 45%, rgba(226, 226, 236, 0.85) 0%, rgba(150, 150, 175, 0.5) 55%, rgba(90, 90, 120, 0) 100%);
}

/* The moon's flare grows out of nothing like the mega laser's ring does. */
.fx-flare {
    border-radius: 50%;
    background: radial-gradient(circle, rgba(255, 255, 255, 0.95) 0%, rgba(214, 200, 255, 0.55) 40%, rgba(43, 2, 99, 0) 72%);
    box-shadow: 0 0 1.5em 0.4em rgba(226, 214, 255, 0.8);
}

.fx-heart {
    background:
        radial-gradient(circle at 30% 30%, #ff8ec0 0%, #f92982 60%, rgba(249, 41, 130, 0) 61%),
        radial-gradient(circle at 70% 30%, #ff8ec0 0%, #f92982 60%, rgba(249, 41, 130, 0) 61%),
        linear-gradient(#f92982, #c10c52);
    clip-path: polygon(50% 100%, 0% 38%, 8% 12%, 30% 5%, 50% 22%, 70% 5%, 92% 12%, 100% 38%);
    filter: drop-shadow(0 0 0.2em rgba(249, 41, 130, 0.7));
}

/* A fire bubble's detonation ring, in flame colours rather than the mega laser's violet. */
.fx-shockwave.fx-fire {
    border-color: rgba(255, 224, 150, 0.9);
    background: radial-gradient(circle, rgba(249, 142, 0, 0.5) 0%, rgba(193, 36, 11, 0.25) 55%, rgba(124, 4, 0, 0) 72%);
    box-shadow: 0 0 1.2em 0.3em rgba(249, 142, 0, 0.7);
}

/* How a bubble goes when a special takes it, one look per kind of destruction. */
.bubble.removing-burn {
    transition: transform 0.18s ease-out, opacity 0.18s ease-out, filter 0.18s ease-out;
    transform: translate(-50%, -50%) scale(1.5);
    filter: brightness(2.5) sepia(1) saturate(6) hue-rotate(-20deg);
    opacity: 0;
}

.bubble.removing-shatter {
    transition: transform 0.18s ease-in, opacity 0.18s ease-in, filter 0.18s ease-in;
    transform: translate(-50%, -50%) scale(0.15) rotate(-25deg);
    filter: brightness(1.8) saturate(0.2);
    opacity: 0;
}

.bubble.removing-bloom {
    transition: transform 0.18s ease-out, opacity 0.18s ease-out, filter 0.18s ease-out;
    transform: translate(-50%, -50%) scale(1.8);
    filter: brightness(3);
    opacity: 0;
}

.bubble.removing-special {
    transition: transform 0.18s ease-in, opacity 0.18s ease-in;
    transform: translate(-50%, -50%) scale(0.1) rotate(200deg);
    opacity: 0;
}

/* Reduced motion: the effects still clear exactly the same bubbles, they just aren't paraded
   across the screen -- the engine fast-forwards them and skips the performer. */
@media (prefers-reduced-motion: reduce) {
    .special-agent, .bolt-overlay {
        opacity: 0.35;
    }
}

/* Responsive breakpoints: 320px+ (default/mobile), 768px+ (tablet), 1024px+ (desktop) */
@media (min-width: 768px) {
    .menu-actions {
        flex-direction: row;
        justify-content: center;
    }
}

@media (min-width: 1024px) {
    .top-bar, .game-hud {
        padding-left: var(--space-5);
        padding-right: var(--space-5);
    }
}

/* Blazor framework requirements */
#blazor-error-ui {
    color-scheme: light only;
    background: lightyellow;
    bottom: 0;
    box-shadow: 0 -1px 2px rgba(0, 0, 0, 0.2);
    box-sizing: border-box;
    display: none;
    left: 0;
    padding: 0.6rem 1.25rem 0.7rem 1.25rem;
    position: fixed;
    width: 100%;
    z-index: 1000;
}

    #blazor-error-ui .dismiss {
        cursor: pointer;
        position: absolute;
        right: 0.75rem;
        top: 0.5rem;
    }

.blazor-error-boundary {
    background: url(data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNTYiIGhlaWdodD0iNDkiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgeG1sbnM6eGxpbms9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkveGxpbmsiIG92ZXJmbG93PSJoaWRkZW4iPjxkZWZzPjxjbGlwUGF0aCBpZD0iY2xpcDAiPjxyZWN0IHg9IjIzNSIgeT0iNTEiIHdpZHRoPSI1NiIgaGVpZ2h0PSI0OSIvPjwvY2xpcFBhdGg+PC9kZWZzPjxnIGNsaXAtcGF0aD0idXJsKCNjbGlwMCkiIHRyYW5zZm9ybT0idHJhbnNsYXRlKC0yMzUgLTUxKSI+PHBhdGggZD0iTTI2My41MDYgNTFDMjY0LjcxNyA1MSAyNjUuODEzIDUxLjQ4MzcgMjY2LjYwNiA1Mi4yNjU4TDI2Ny4wNTIgNTIuNzk4NyAyNjcuNTM5IDUzLjYyODMgMjkwLjE4NSA5Mi4xODMxIDI5MC41NDUgOTIuNzk1IDI5MC42NTYgOTIuOTk2QzI5MC44NzcgOTMuNTEzIDI5MSA5NC4wODE1IDI5MSA5NC42NzgyIDI5MSA5Ny4wNjUxIDI4OS4wMzggOTkgMjg2LjYxNyA5OUwyNDAuMzgzIDk5QzIzNy45NjMgOTkgMjM2IDk3LjA2NTEgMjM2IDk0LjY3ODIgMjM2IDk0LjM3OTkgMjM2LjAzMSA5NC4wODg2IDIzNi4wODkgOTMuODA3MkwyMzYuMzM4IDkzLjAxNjIgMjM2Ljg1OCA5Mi4xMzE0IDI1OS40NzMgNTMuNjI5NCAyNTkuOTYxIDUyLjc5ODUgMjYwLjQwNyA1Mi4yNjU4QzI2MS4yIDUxLjQ4MzcgMjYyLjI5NiA1MSAyNjMuNTA2IDUxWk0yNjMuNTg2IDY2LjAxODNDMjYwLjczNyA2Ni4wMTgzIDI1OS4zMTMgNjcuMTI0NSAyNTkuMzEzIDY5LjMzNyAyNTkuMzEzIDY5LjYxMDIgMjU5LjMzMiA2OS44NjA4IDI1OS4zNzEgNzAuMDg4N0wyNjEuNzk1IDg0LjAxNjEgMjY1LjM4IDg0LjAxNjEgMjY3LjgyMSA2OS43NDc1QzI2Ny44NiA2OS43MzA5IDI2Ny44NzkgNjkuNTg3NyAyNjcuODc5IDY5LjMxNzkgMjY3Ljg3OSA2Ny4xMTgyIDI2Ni40NDggNjYuMDE4MyAyNjMuNTg2IDY2LjAxODNaTTI2My41NzYgODYuMDU0N0MyNjEuMDQ5IDg2LjA1NDcgMjU5Ljc4NiA4Ny4zMDA1IDI1OS43ODYgODkuNzkyMSAyNTkuNzg2IDkyLjI4MzcgMjYxLjA0OSA5My41Mjk1IDI2My41NzYgOTMuNTI5NSAyNjYuMTE2IDkzLjUyOTUgMjY3LjM4NyA5Mi4yODM3IDI2Ny4zODcgODkuNzkyMSAyNjcuMzg3IDg3LjMwMDUgMjY2LjExNiA4Ni4wNTQ3IDI2My41NzYgODYuMDU0N1oiIGZpbGw9IiNGRkU1MDAiIGZpbGwtcnVsZT0iZXZlbm9kZCIvPjwvZz48L3N2Zz4=) no-repeat 1rem/1.8rem, #b32121;
    padding: 1rem 1rem 1rem 3.7rem;
    color: white;
}

    .blazor-error-boundary::after {
        content: "An error has occurred."
    }

/* --- Boot splash -------------------------------------------------------------------------
   The screen both hosts show while the app is still starting up: the same lockup the app
   icon and the native splash screen use, over the brand purple, with the shooter firing
   under it. It lives in index.html rather than in a component because it has to be on
   screen before Blazor is, so it is plain markup styled from here and nothing else.

   On the WebAssembly host Blazor sets --blazor-load-percentage on the document while it
   downloads, and the bar tracks it. The MAUI host has nothing to download and no such
   variable, so its markup carries .boot-splash--indeterminate and the bar just sweeps. */

.boot-splash {
    position: fixed;
    inset: 0;
    z-index: 900;
    display: flex;
    flex-direction: column;
    align-items: center;
    justify-content: center;
    gap: clamp(0.5rem, 2.5vh, 1.75rem);
    padding: calc(env(safe-area-inset-top) + 1rem) 1.5rem
             calc(env(safe-area-inset-bottom) + 1.5rem);
    overflow: hidden;
    text-align: center;
    color: #ffffff;
    background: radial-gradient(125% 85% at 50% 16%, #8a67ea 0%, #5a3fc4 40%, #2b1c66 100%);
}

    /* Stray bubbles drifting in the background, drawn as gradient dots so the splash stays
       a single image request. */
    .boot-splash::before {
        content: "";
        position: absolute;
        inset: -10%;
        pointer-events: none;
        background:
            radial-gradient(circle 22px at 12% 20%, rgba(255, 255, 255, 0.20) 0 82%, transparent 100%),
            radial-gradient(circle 13px at 86% 13%, rgba(255, 255, 255, 0.16) 0 82%, transparent 100%),
            radial-gradient(circle 17px at 22% 82%, rgba(255, 255, 255, 0.14) 0 82%, transparent 100%),
            radial-gradient(circle 26px at 91% 70%, rgba(255, 255, 255, 0.13) 0 82%, transparent 100%),
            radial-gradient(circle 10px at 5% 54%, rgba(255, 255, 255, 0.12) 0 82%, transparent 100%),
            radial-gradient(circle 14px at 66% 92%, rgba(255, 255, 255, 0.10) 0 82%, transparent 100%);
        animation: boot-splash-drift 9s ease-in-out infinite alternate;
    }

.boot-splash__logo {
    position: relative;
    width: min(84vw, 28rem);
    max-height: 36vh;
    object-fit: contain;
}

/* The shooter, the beam it fires along and the bubble travelling up it. */
.boot-splash__launch {
    position: relative;
    display: flex;
    flex-direction: column;
    align-items: center;
    justify-content: flex-end;
    height: clamp(6rem, 18vh, 10rem);
}

.boot-splash__shot {
    position: absolute;
    bottom: 62%;
    width: clamp(1.6rem, 6vw, 2.4rem);
    animation: boot-splash-shot 1.8s ease-out infinite;
}

.boot-splash__beam {
    position: absolute;
    bottom: 58%;
    width: 0.7rem;
    height: clamp(2.5rem, 9vh, 4.5rem);
    border-radius: 999px;
    background: linear-gradient(to top, rgba(255, 255, 255, 0.75), rgba(255, 255, 255, 0));
    filter: blur(2px);
}

.boot-splash__cannon {
    position: relative;
    width: clamp(5.5rem, 32vw, 8.5rem);
}

.boot-splash__label {
    position: relative;
    font-size: 0.95rem;
    font-weight: 800;
    letter-spacing: 0.16em;
    text-transform: uppercase;
    text-shadow: 0 2px 0 #141f52;
}

    /* Blazor publishes its download percentage as a quoted string ready to drop into
       content, so the label grows from "Loading" to "Loading 42%" as the app comes down. */
    .boot-splash__label::after {
        content: " " var(--blazor-load-percentage-text, "");
    }

    .boot-splash--indeterminate .boot-splash__label::after {
        content: "...";
    }

.boot-splash__track {
    position: relative;
    display: block;
    width: min(68vw, 19rem);
    height: 1.15rem;
    padding: 0.16rem;
    border: 0.18rem solid #141f52;
    border-radius: 999px;
    background: rgba(13, 20, 64, 0.55);
    overflow: hidden;
}

.boot-splash__fill {
    display: block;
    width: var(--blazor-load-percentage, 0%);
    height: 100%;
    border-radius: 999px;
    background: linear-gradient(#ffe98a, #f9b32b 55%, #e8890c);
    transition: width 0.15s ease-out;
}

    /* Nothing reports progress on the native host, so the bar sweeps instead of filling. */
    .boot-splash--indeterminate .boot-splash__fill {
        width: 42%;
        transition: none;
        animation: boot-splash-sweep 1.5s ease-in-out infinite;
    }

@keyframes boot-splash-drift {
    from { transform: translate3d(0, 0, 0); }
    to { transform: translate3d(0, -2%, 0); }
}

@keyframes boot-splash-shot {
    0% { transform: translateY(0.5rem) scale(0.7); opacity: 0; }
    18% { opacity: 1; }
    100% { transform: translateY(-5.5rem) scale(1); opacity: 0; }
}

@keyframes boot-splash-sweep {
    0% { transform: translateX(-108%); }
    100% { transform: translateX(240%); }
}

code {
    color: #ff8fc4;
}

/* Accessibility: respect the user's OS-level motion and contrast preferences. */
@media (prefers-reduced-motion: reduce) {
    *, *::before, *::after {
        animation-duration: 0.001ms !important;
        animation-iteration-count: 1 !important;
        transition-duration: 0.001ms !important;
    }
}

@media (prefers-contrast: more) {
    :root {
        --color-bg: #000000;
        --color-surface: #0a0a0a;
        --color-surface-raised: #1a1a1a;
        --color-text: #ffffff;
        --color-text-muted: #d0d0d0;
        --color-border: #ffffff;
    }

    .btn, .overlay-panel, .settings-list, .top-bar, .game-hud {
        border-width: 2px;
    }
}
