ui/ashoverlay.razor.scss
// ASH OVERLAY — drifting soot while you stand in a fog area.
//
// ⚠️ Opacity is written from code every frame; only the DRIFT lives here. See the header in
// AshOverlay.razor for why this splits differently from DamageOverlay, which drives both from code.

.ash
{
    position: absolute;
    width: 100%;
    height: 100%;
    pointer-events: none;

    // ⛔ BEHIND THE HUD, like the blood vignette. Ash over a points counter or an ammo readout is
    // the same mistake as a health bar you cannot read at low health.
    z-index: -1;

    // ⛔ CLIPS THE OVERSIZED LAYERS. Each one below is 200% and offset by -50% so it always has
    // tile to spare in the direction it drifts; without this they would paint outside the root and
    // over whatever sits beside it.
    overflow: hidden;
}

.layer
{
    position: absolute;

    // ⚠️ 200% AND CENTRED, NOT 100%. A layer that exactly covers the screen runs out of texture at
    // the moment it has translated a full tile, and the loop shows as a visible jump at the edge.
    width: 200%;
    height: 200%;
    left: -50%;
    top: -50%;

    background-repeat: repeat;

    // Set from code every frame. Starting at 0 means walking into a map never flashes a full
    // overlay for the frame before OnUpdate first runs.
    opacity: 0;
}

// ⛔ EVERY LAYER TRANSLATES BY EXACTLY ITS OWN background-size. 900 here, 420 and 700 below — the
// keyframe distance and the tile size are the same number on purpose. Any other distance leaves a
// seam crossing the screen once per loop, which reads as a rendering fault rather than as ash.

.soot
{
    background-image: url( "ui/nz/ash/nz.ash.soot.png" );
    background-size: 900px 900px;
    animation: nz-ash-soot 60s linear infinite;
}

.motes-far
{
    background-image: url( "ui/nz/ash/nz.ash.motes.png" );
    background-size: 420px 420px;
    animation: nz-ash-far 34s linear infinite;
}

.motes-near
{
    background-image: url( "ui/nz/ash/nz.ash.motes.png" );

    // ⚠️ THE SAME TEXTURE AT A BIGGER TILE, not a second texture. Scaling it up is what makes these
    // specks read as nearer than the ones behind them, and it costs no extra asset.
    background-size: 700px 700px;
    animation: nz-ash-near 19s linear infinite;
}

// ⛔ AT FILE SCOPE, NOT NESTED. s&box's SCSS does not hoist a @keyframes out of a selector block —
// nested, the rule compiles and the animation simply never runs, which looks like frozen ash.
// MapBrowser.razor.scss records the same trap.
//
// ⚠️ ALL THREE FALL, AND NONE FALLS STRAIGHT DOWN. Pure vertical reads as rain; the sideways
// component is what makes it drift. They disagree about direction on purpose — three layers sliding
// the same way is one moving picture, not depth.

@keyframes nz-ash-soot
{
    0%   { transform: translateX( 0px ) translateY( 0px ); }
    100% { transform: translateX( 900px ) translateY( 900px ); }
}

@keyframes nz-ash-far
{
    0%   { transform: translateX( 0px ) translateY( 0px ); }
    100% { transform: translateX( -420px ) translateY( 420px ); }
}

@keyframes nz-ash-near
{
    0%   { transform: translateX( 0px ) translateY( 0px ); }
    100% { transform: translateX( -700px ) translateY( 700px ); }
}