ui/survivalhud.razor.scss
// SURVIVAL HUD
//
// Offsets mirror cl_hud_t5.lua's own anchors, converted from its 1920-wide
// reference to px here — the ScreenPanel is set to ConsistentHeight scaling, so
// these stay put across resolutions the same way the original's *scale does.
//
// ⚠️ Default font throughout. The original uses "Black Ops 1" for mainfont and
// smallfont; those are addon font files we cannot reference yet. Sizes and
// weights are picked to survive the swap without relayout.
.hud
{
position: absolute;
width: 100%;
height: 100%;
pointer-events: none; // never eat clicks — the tool gun fires through it
font-family: Inter; // the shipped face; stands in for Black Ops 1
color: white;
// ── BOTTOM LEFT — health ─────────────────────────────────────────────────
//
// The original does have a left-hand health bar — it is what
// nz_hud_health_style 1 switches to (wr = 34*scale in cl_hud_t5.lua) — so
// this is the skin's other supported side, not an invention.
.left
{
position: absolute;
left: 40px;
bottom: 60px;
flex-direction: column;
}
.left
{
gap: 10px;
}
// One row per meter: icon, then bar. Both icons occupy the SAME width so
// the two bars line up on their left edge — a 2px stagger between stacked
// bars is the kind of thing that looks broken without being obviously wrong.
.meter
{
flex-direction: row;
align-items: center;
gap: 10px;
}
.hp-icon,
.boot,
.banana,
.armor-icon,
.charge-icon,
.focus-icon,
.streak-icon
{
width: 26px;
justify-content: center;
align-items: center;
}
// Medical cross. Sized and coloured to read as a label for the bar, not as
// a button — it is never interactive.
.hp-icon
{
font-size: 34px;
font-weight: 700;
color: rgba( 235, 235, 235, 0.9 );
text-shadow: 0px 2px 5px rgba( 0, 0, 0, 0.9 );
// Nudge: a "+" glyph sits high in its line box, so it reads as
// floating above the bar's centre unless pulled down.
position: relative;
top: -2px;
}
// Boot, built from two blocks — a shaft and a sole forming an L.
//
// ⚠️ NOT a glyph. Inter ships no boot, and an emoji (👢) falls back to a
// blank box or tofu depending on the platform's font chain. Two divs always
// render, at any size, on any machine.
.boot
{
height: 26px;
position: relative;
.shaft,
.foot
{
position: absolute;
background-color: rgba( 235, 235, 235, 0.9 );
}
.shaft
{
left: 5px;
top: 3px;
width: 9px;
height: 14px;
}
// Wider than the shaft and rounded at the toe, which is what reads as
// a boot rather than an L.
.foot
{
left: 5px;
top: 15px;
width: 17px;
height: 7px;
border-radius: 0px 3px 3px 0px;
}
}
// ── BOTTOM RIGHT — round beside the weapon ───────────────────────────────
.round
{
color: rgba( 235, 60, 45, 1 ); // the CoD blood red
text-shadow: 0px 2px 6px rgba( 0, 0, 0, 0.9 );
// ── between rounds ───────────────────────────────────────────────
//
// White and breathing until the next number appears. The colour change
// is the signal that the wave is over; the pulse is what stops it
// reading as a HUD element that simply went pale.
//
// ⚠️ `ease-in-out` IS the sine. A linear 20→100→20 has a visible corner
// at each end where the direction reverses; ease-in-out eases through
// both extremes, which is what a sine wave looks like and what was
// asked for. Writing the curve by hand in keyframe steps would be a
// worse approximation of the same thing.
&.prep
{
color: rgba( 255, 255, 255, 1 );
animation: round-breathe 1.6s infinite;
animation-timing-function: ease-in-out;
}
// ── changing number ──────────────────────────────────────────────
//
// ⛔ TWO SEPARATE ANIMATIONS, NOT A CROSS-FADE. The glyph is swapped in
// C# at the moment opacity reaches zero, so these never overlap — one
// element goes fully out, changes, and comes back. A true cross-fade
// would need two elements stacked, and at this size the overlap would
// read as a double image rather than a blend.
//
// ⚠️ `forwards` on the fade-out so it HOLDS at zero. Without it the
// number snaps back to full opacity on the last frame, which is a
// flash exactly where the swap happens.
&.fade-out
{
animation: round-fade-out 0.35s;
animation-fill-mode: forwards;
animation-timing-function: ease-in;
}
&.fade-in
{
animation: round-fade-in 0.35s;
animation-timing-function: ease-out;
}
// Nudged down and left. Offsets rather than margins on purpose — the
// weapon block sits in the same flex row and must not move, and a
// margin here would reflow it.
position: relative;
top: 10px;
right: 14px;
// Tally and digits are DIFFERENT SIZES on purpose. Five strokes at digit
// size runs far wider than a two-digit number and unbalances the corner;
// the original sidesteps this by drawing a 150px tally sprite against
// per-digit sprites.
&.tally
{
font-size: 64px;
font-weight: 500;
letter-spacing: 6px;
}
&.digits
{
font-size: 72px;
font-weight: 700;
letter-spacing: 2px;
}
}
.right
{
position: absolute;
right: 40px;
bottom: 48px;
flex-direction: column;
align-items: flex-end;
// Lifts the points clear of the round/weapon row. The weapon block is
// the anchor — it must not move — so all the spacing is spent above it.
gap: 62px;
}
// Round and weapon share a row so the weapon keeps its exact position and
// the round grows leftward from it. align-items: flex-end puts the round's
// baseline on the ammo's, rather than floating it against the taller box.
.lower
{
flex-direction: row;
align-items: flex-end;
gap: 26px;
}
// Team roster ---------------------------------------------------------------
// ⚠️ THE SAME READING ORDER AS `.points` — a face, then a number — one size
// down, with the name to the LEFT of the face so the two blocks line up on
// their right edge. `.right` is `align-items: flex-end`, so everything here
// grows leftward from the edge the big score sits on.
//
// ⛔ AND IT LIVES ABOVE `.points` IN THE MARKUP. See the razor comment: the
// popup panel's offsets are hardcoded against this column's bottom, so any
// height added BELOW the score moves the score and not the popups.
.mate
{
flex-direction: row;
align-items: center;
gap: 10px;
margin-bottom: 10px;
// So the "+N" can be positioned against the ROW rather than the column.
position: relative;
.portrait
{
width: 42px;
height: 42px;
border-radius: 4px;
overflow: hidden;
background-color: rgba( 0, 0, 0, 0.35 );
.face { width: 100%; height: 100%; }
}
.pts
{
font-size: 26px;
color: rgba( 255, 235, 150, 0.85 );
// Same reasoning as `.points .value`'s fixed width, at this scale:
// right-aligned digits in a proportional face jitter the row's left
// edge on every award, which drags the face with it.
min-width: 92px;
text-align: right;
font-variant-numeric: tabular-nums;
}
// ⚠️ RISES OVER THE NUMBER IT BELONGS TO. Anchored to this row's top-right,
// which is where `.pts` is — the previous `right: 100px` put it out to the
// left of the face, over nothing.
.gain
{
position: absolute;
top: -16px;
right: 4px;
font-size: 20px;
color: rgba( 190, 255, 170, 1 );
animation: mate-gain 1.4s ease-out forwards;
pointer-events: none;
}
}
@keyframes mate-gain
{
0% { opacity: 0; transform: translateY( 6px ); }
15% { opacity: 1; transform: translateY( 0px ); }
70% { opacity: 1; transform: translateY( -6px ); }
100% { opacity: 0; transform: translateY( -14px ); }
}
// Points ------------------------------------------------------------------
.points
{
flex-direction: row;
align-items: center;
gap: 10px;
// Kept so anything absolutely positioned inside the points block
// measures against IT rather than the whole bottom-right column.
// (The "+50" popups used to live here and needed this; they are their
// own panel now — see PointsPopupHud.)
position: relative;
.value
{
// ⛔ FIXED WIDTH, OR THE WHOLE BLOCK MOVES ON EVERY AWARD. `.right`
// is `align-items: flex-end`, so `.points` is right-aligned and grows
// LEFTWARD. This div renders the live score in a proportional face,
// where "1" is narrower than "8" — so almost any change in the number
// changes this width, which moves the block's left edge, which moves
// the portrait, which moves every popup anchored to it.
//
// ⚠️ THAT IS THE "THEY ALL STUTTER AND GO LEFT" REPORT. It was never
// the animation: the numbers were being carried sideways by the
// counter reflowing underneath them. Nothing about the popups could
// have fixed it.
//
// 160px holds six digits at this size, so the layout is stable across
// any realistic score.
min-width: 160px;
text-align: right;
font-size: 46px;
font-weight: 700;
color: rgba( 255, 232, 150, 1 ); // warm gold, as in BO1
text-shadow: 0px 2px 6px rgba( 0, 0, 0, 0.9 );
}
// Square frame around the portrait image. Set PortraitImage on the
// component to use custom art; empty falls back to the Steam avatar.
.portrait
{
width: 52px;
height: 52px;
overflow: hidden;
background-color: rgba( 0, 0, 0, 0.55 );
border: 2px solid rgba( 255, 232, 150, 0.75 );
.face
{
width: 100%;
height: 100%;
// Fill the square whatever the source aspect is, rather than
// letterboxing an avatar inside its own frame.
background-size: cover;
background-position: center;
}
}
}
// Health ------------------------------------------------------------------
// 256x24 in the original; kept proportional.
// ⚠️ The empty state has to be legible. At 0 health the fill has no width,
// so all that is left is this backing — and a translucent black box on a
// black-and-grey map is invisible. It read as "the health bar disappeared"
// rather than "you have no health". Opaque backing plus a light border, so
// the EMPTY bar is still obviously a bar.
// Armor -------------------------------------------------------------------
//
// ⚠️ THE THREE SEGMENTS TOGETHER ARE 256px — the same as .health and .stamina.
// The container is pinned to that width and the segments flex-grow into it, so
// the gaps come OUT of the total rather than being added on top. Sizing each
// segment to a hard 82px instead would make the row 266px wide once the two 5px
// gaps were counted, and the stack would sit visibly ragged against the two bars
// below it.
.armor
{
width: 256px;
height: 18px;
flex-direction: row;
gap: 5px;
.seg
{
flex-grow: 1;
height: 100%;
background-color: rgba( 0, 0, 0, 0.8 );
}
// Owned. Bright border and a live fill.
.seg.unlocked
{
border: 2px solid rgba( 255, 255, 255, 0.3 );
}
// ⚠️ NOT HIDDEN, DIMMED. A tier you have not bought still draws, because
// the empty slot is what tells you the vest can be upgraded at all. Hiding
// them would make a tier-1 player's bar look like a full bar a third the
// width, which reads as damage rather than as headroom.
.seg.locked
{
border: 2px solid rgba( 255, 255, 255, 0.10 );
background-color: rgba( 0, 0, 0, 0.45 );
}
.fill
{
height: 100%;
// Steel blue, so it is not confused with health's white or stamina's
// pale blue at a glance.
background-color: rgba( 120, 175, 225, 0.95 );
transition: width 0.12s ease-out;
}
}
// A shield, built from divs. Same reasoning as the boot: Inter ships no shield
// glyph and an emoji falls back to tofu. A square with its bottom corners
// rounded hard reads as a shield at this size.
.armor-icon
{
height: 26px;
justify-content: center;
align-items: center;
.shield
{
width: 15px;
height: 17px;
background-color: rgba( 235, 235, 235, 0.9 );
border-radius: 3px 3px 8px 8px;
}
}
// Plates in reserve, right of the bar.
.plates
{
flex-direction: row;
align-items: center;
gap: 5px;
margin-left: 4px;
.plate-icon
{
width: 9px;
height: 14px;
background-color: rgba( 120, 175, 225, 0.95 );
border-radius: 2px;
}
.plate-count
{
font-size: 17px;
font-weight: 700;
color: rgba( 235, 235, 235, 0.95 );
text-shadow: 0px 2px 5px rgba( 0, 0, 0, 0.9 );
}
// Carrying none: dimmed rather than hidden, so the slot's existence is
// still visible and "go find plates" is legible.
&.empty
{
.plate-icon { background-color: rgba( 255, 255, 255, 0.22 ); }
.plate-count { color: rgba( 235, 235, 235, 0.45 ); }
}
}
.health
{
width: 256px;
height: 18px;
background-color: rgba( 0, 0, 0, 0.8 );
border: 2px solid rgba( 255, 255, 255, 0.3 );
// ⚠️ POSITIONED, so Leech's overheal below lays itself over the fill rather than somewhere up the tree.
position: relative;
// ⚠️ LEECH'S OVERHEAL (2026-10-04): gold over the fill, from the left, on the bar's own scale.
.over
{
position: absolute;
left: 0px;
top: 0px;
height: 100%;
background-color: rgba( 255, 206, 84, 0.95 );
}
.fill
{
height: 100%;
background-color: rgba( 235, 235, 235, 0.95 );
transition: width 0.12s ease-out, background-color 0.2s linear;
}
&.low .fill
{
background-color: rgba( 220, 45, 35, 1 );
}
}
// Stamina ----------------------------------------------------------------
// Same dimensions as health. The two are told apart by their ICON and FILL
// COLOUR rather than by size — keep these three values in step with
// .health above, or the stacked pair goes ragged down the right edge.
// ⛔ A BANANA FROM ONE ROTATED BLOCK, for the reason `.boot` above already documents: the
// shipped font has no banana glyph and an emoji falls back to a blank box. A curved crescent is
// not worth a background image, so this is a rounded rectangle tilted off-axis — enough to read
// as fruit next to a cross and a boot, which is all the label has to do.
.banana
{
height: 26px;
position: relative;
.peel
{
position: absolute;
left: 6px;
top: 3px;
width: 9px;
height: 20px;
background-color: rgba( 255, 237, 115, 0.95 );
// Fat on one side, thin on the other, which is what makes it read as a
// crescent rather than as a capsule.
border-radius: 2px 7px 3px 7px;
transform: rotate( 22deg );
}
}
// ⚠️ THE SAME 256px WIDTH AND 18px HEIGHT AS HEALTH AND STAMINA, so the three stack as one
// column. A bar that agreed on colour but not on geometry would read as a different kind of
// thing sitting underneath them.
.place-charge
{
width: 256px;
height: 18px;
background-color: rgba( 0, 0, 0, 0.8 );
border: 2px solid rgba( 255, 255, 255, 0.3 );
.fill
{
height: 100%;
// The placeables' own light yellow, so the bar and the thing it places are
// obviously the same system.
background-color: rgba( 255, 237, 115, 0.9 );
transition: width 0.1s linear, background-color 0.2s linear;
}
// ⛔ FULL IS THE ONLY STATE WORTH A COLOUR CHANGE, and it is the opposite of health and
// stamina, where the highlighted state is the BAD one. Here full means the key will work,
// which is the single thing the player is waiting to be told — so the border lights too,
// not just the fill, because a fill at 100% has no edge left to distinguish it.
&.ready
{
border-color: rgba( 255, 237, 115, 0.9 );
.fill
{
background-color: rgba( 255, 252, 205, 1 );
}
}
// ⚠️ PAUSED: one of yours is out and kills do not charge the next (2026-10-04). Dim, so it reads as waiting rather
// than broken.
&.paused
{
border-color: rgba( 255, 255, 255, 0.12 );
.fill
{
background-color: rgba( 255, 237, 115, 0.3 );
}
}
}
.stamina
{
width: 256px;
height: 18px;
background-color: rgba( 0, 0, 0, 0.8 );
border: 2px solid rgba( 255, 255, 255, 0.3 );
.fill
{
height: 100%;
background-color: rgba( 190, 205, 225, 0.9 );
transition: width 0.1s linear, background-color 0.2s linear;
}
// Empty, not merely low: this is the moment sprint is actually blocked,
// so it earns a colour change the way "nearly empty" does not.
&.low .fill
{
background-color: rgba( 225, 150, 40, 1 );
}
}
// Weapon ------------------------------------------------------------------
.weapon
{
flex-direction: column;
align-items: flex-end;
// The weapon name and, when the gun has an ammo mod, its badge beside it.
//
// ⚠️ ROW-REVERSE, NOT ROW. This column is right-aligned, so the name's
// right edge is the line everything else on the column lines up against. Putting
// the badge after the name in flow order would push the name left by the badge's
// width and only when a mod is held — the name would visibly shift when you
// bought one. Reversed, the badge hangs off the left and the name never moves.
.name-row
{
flex-direction: row-reverse;
align-items: center;
justify-content: flex-start;
gap: 7px;
}
.name
{
font-size: 22px;
font-weight: 600;
letter-spacing: 2px;
// ⚠️ MIRRORED IN C# AS SurvivalHud.NeutralNameColor, which now writes this
// inline on every build. This declaration is the fallback for the frame before the
// first build; the two must agree or a Common weapon changes colour on spawn.
color: rgba( 225, 225, 225, 0.9 );
text-shadow: 0px 2px 5px rgba( 0, 0, 0, 0.9 );
}
// The ammo mod badge — upstream's own HUD artwork.
//
// ⚠️ THE DEFAULT STATE IS DIM, because a mod on cooldown is the common
// case for a 10s recharge. The lit state is the exception and so it is the one
// that gets full opacity, not the other way round.
//
// ⚠️ SIZED TO THE CAP HEIGHT of the 22px name, not to the name's line
// box. The source icons are 227x227 with generous transparent padding, so a badge
// matching the full line height reads as much smaller than the text next to it.
.mod-badge
{
width: 26px;
height: 26px;
opacity: 0.45;
transition: opacity 0.15s ease-out;
// Ready: full brightness.
&.ready { opacity: 1; }
// ⛔ AN UNBUILT MOD IS DESATURATED AND DIMMER whatever else it is, so a
// catalogue entry cannot be mistaken for a working mod that happens to be
// recharging. Five of the six are still catalogue entries.
&.unwired
{
opacity: 0.3;
filter: saturate( 0 );
}
}
.ammo
{
flex-direction: row;
align-items: baseline;
gap: 6px;
// ⚠️ THE SALVAGE CHIP OPTS OUT OF THE BASELINE. This row is
// baseline-aligned so the 46px clip and the smaller reserve sit on one
// line — but the salvage chip is a nested flex box whose own baseline
// comes from its text, which would hang it off the bottom of a 46px
// number. Centring it against that number is what reads as "beside".
//
// ⚠️ EXTRA RIGHT MARGIN ON TOP OF THE 6px GAP, because the chip is
// a different kind of thing from the two numbers it now shares a row
// with. Without it the salvage count reads as part of the ammo figure.
.salvage
{
align-self: center;
margin-right: 6px;
}
// ⚠️ THE WEAPON TECH TAG (2026-10-04): Select Fire's mode and the per-class augments' readouts, beside the clip.
.techtag
{
align-self: center;
margin-right: 6px;
font-size: 15px;
font-weight: 700;
letter-spacing: 2px;
color: rgba( 150, 200, 255, 0.85 );
&.flash { color: rgba( 255, 255, 255, 1 ); }
&.ready { color: rgba( 255, 200, 80, 1 ); }
}
text-shadow: 0px 2px 6px rgba( 0, 0, 0, 0.9 );
// Magazine is the number you act on, so it carries the weight.
.clip
{
font-size: 46px;
font-weight: 700;
}
.sep
{
font-size: 28px;
color: rgba( 255, 255, 255, 0.45 );
}
.reserve
{
font-size: 28px;
font-weight: 500;
color: rgba( 255, 255, 255, 0.65 );
}
}
.reloading
{
font-size: 16px;
font-weight: 600;
letter-spacing: 3px;
color: rgba( 255, 200, 80, 1 );
text-shadow: 0px 2px 5px rgba( 0, 0, 0, 0.9 );
}
// Grenades, under the ammo.
//
// ⚠️ Right-aligned with the ammo above it rather than centred — this corner
// reads bottom-up along a right edge, and a centred row would break that line.
//
// ⚠️ Deliberately smaller and dimmer than the clip count. It is equipment you
// check between fights, not the number you watch while shooting; matching the
// ammo's weight would give the corner two things competing to be read first.
.grenades
{
flex-direction: row;
align-items: center;
justify-content: flex-end;
margin-top: 6px;
gap: 6px;
.nade-icon
{
font-size: 20px;
color: rgba( 120, 200, 120, 0.9 );
text-shadow: 0px 2px 5px rgba( 0, 0, 0, 0.9 );
}
.nade-count
{
font-size: 26px;
font-weight: bold;
color: rgba( 255, 255, 255, 0.85 );
text-shadow: 0px 2px 5px rgba( 0, 0, 0, 0.9 );
}
// ⛔ THE COOK STATE HAS TO BE LOUD. A grenade whose fuse is running in
// your hand kills you in five seconds; this is the only warning, so it
// goes red and pulses rather than shading a little warmer.
&.cooking
{
.nade-icon, .nade-count
{
color: rgba( 255, 90, 70, 1 );
}
animation: nade-pulse 0.4s infinite;
}
}
}
}
@keyframes nade-pulse
{
0% { opacity: 1; }
50% { opacity: 0.35; }
100% { opacity: 1; }
}
// ⚠️ 20% to 100%, as asked — the low end is deliberately NOT 0. A number that
// vanishes completely between rounds reads as the HUD dropping out; keeping a
// fifth of it visible makes it a pulse rather than a blink.
@keyframes round-breathe
{
0% { opacity: 0.2; }
50% { opacity: 1; }
100% { opacity: 0.2; }
}
@keyframes round-fade-out
{
0% { opacity: 1; }
100% { opacity: 0; }
}
@keyframes round-fade-in
{
0% { opacity: 0; }
100% { opacity: 1; }
}
// ⛔ THE CLOSING BRACE ABOVE WAS MISSING, and it cost every rule that follows. A
// `@keyframes` block only accepts percentage selectors, so the parser hit `.charge` and
// reported `Expected %` - then swallowed `.charge`, `.charge-icon`, `.focus`, `.focus-mult`,
// `.focus-icon` and `.streak-icon` into the keyframes body, where none of them applied to
// anything. The trigger-charge bar, the Focus bar and both their icons have been UNSTYLED
// since the day they were added: 26px content-sized boxes rather than 256x18 bars.
//
// ⚠ THE ENGINE SAID SO, ONCE, AT STARTUP: "Skipping malformed rule in
// /ui/survivalhud.razor.scss: Expected % [:563]". It scrolled past unread while the bar was
// diagnosed by eye from a screenshot instead - INSTRUCTIONS.md §7 exactly, and §6 on top of
// it. `read_console` with no filter would have answered this in one call.
// Trigger charge (Double Tap M3) ------------------------------------------
//
// ⚠️ SAME 256x18 AS HEALTH AND STAMINA, and it has to be — the three stack in one
// column and any difference in width shows as a ragged right edge. Keep these three
// values in step with `.health` and `.stamina` above, which already carry that warning
// for each other.
//
// ⛔ THE FILL HAS NO COLOUR HERE. Both the width and the background are written from
// `SurvivalHud.TickTriggerCharge` every frame, because the charge moves too fast for
// this panel's deliberately coarse BuildHash to track. A colour declared here would be
// overwritten on the first frame and would only ever be visible for that one frame,
// which is worse than no declaration at all — it would read as the authority.
.charge
{
width: 256px;
height: 18px;
background-color: rgba( 0, 0, 0, 0.8 );
border: 2px solid rgba( 255, 255, 255, 0.3 );
.fill
{
height: 100%;
// ⚠️ NO `transition` ON WIDTH, UNLIKE HEALTH'S 0.12s. Health changes in steps
// — a hit lands and the bar jumps — so easing it reads as smoothing. This one
// is already continuous at frame rate, and a transition chasing a value that
// moves every frame lags permanently behind it: the bar would read empty while
// the gun was still hitting hard.
//
// ⚠️ The COLOUR does transition, because that changes in three discrete steps
// and a hard snap between bands reads as a flicker.
transition: background-color 0.15s linear;
}
}
// A ringed dot — a charge/cell mark, built from two blocks for the same reason the
// boot is (Inter ships no suitable glyph and emoji fall back to tofu).
.charge-icon
{
height: 26px;
position: relative;
justify-content: center;
align-items: center;
.ring
{
width: 17px;
height: 17px;
border-radius: 9px;
border: 2px solid rgba( 235, 235, 235, 0.9 );
}
// ⚠️ ABSOLUTE AND CENTRED ON THE RING, not a child of it — a child would be laid
// out inside the ring's content box and pushed off-centre by its own border.
.dot
{
position: absolute;
left: 9px;
top: 9px;
width: 8px;
height: 8px;
border-radius: 4px;
background-color: rgba( 235, 235, 235, 0.9 );
}
}
// Deadshot Focus (M4) -----------------------------------------------------
//
// ⚠️ NARROWER THAN THE OTHER METERS, at 200px against their 256, because this row
// carries a MULTIPLIER LABEL to its right and the whole left column has to keep one
// right edge. 200 + gap + the label lands on the same 256 the bars above use.
//
// ⛔ THE FILL HAS NO COLOUR HERE, same as `.charge`. Both the width and the background
// are written from `SurvivalHud.TickFocus`; a colour declared here would be overwritten
// on the first frame and would only ever be visible for that one frame, which is worse
// than no declaration because it reads as the authority.
.focus
{
// ⛔ 256 x 18, THE SAME AS `.health` AND `.stamina`, AND THE LABEL MOVED INSIDE
// TO PAY FOR IT. The note above records why this was 200: 200 + gap + a 46px label
// came to the same 256 the bars above use, so the whole left column kept one right
// edge. Widening the bar with the label still outside would have pushed this one row
// 46px past every other - so the label is now absolutely positioned inside the bar's
// right end. Same 256 edge, full-size bar, label still readable.
width: 256px;
height: 18px;
// ⚠ NEEDED BY THE LABEL, which is `position: absolute` against this box. Without
// it the label resolves against the whole HUD and lands nowhere near the bar.
position: relative;
background-color: rgba( 0, 0, 0, 0.8 );
border: 2px solid rgba( 255, 255, 255, 0.3 );
.fill
{
height: 100%;
// ⚠️ A transition HERE but not on the charge bar, and the difference is real:
// Focus moves in DISCRETE STEPS (one headshot kill), so easing reads as the bar
// filling. The charge bar is already continuous at frame rate and a transition
// would lag permanently behind it.
transition: width 0.18s ease-out, background-color 0.2s linear;
}
}
// ⚠️ TABULAR-ISH BY FIXED WIDTH rather than by font feature. The label runs from "×1"
// to "×5" and back to "×1"; letting it size to content would shift the whole row
// sideways on every kill.
// ⛔ `.focus-mult` REMOVED, NOT LEFT AS DEAD CSS. The Focus bar carries no number any
// more, so the rule matched nothing — and an unused selector in this file is exactly what
// hid two unclosed `@keyframes` for weeks: rules that never applied and never complained.
// If a figure comes back, it comes back with a fresh rule and a reason.
// A stacked chevron pair — "building up", and visually distinct from the charge ring
// so the two augment bars are never mistaken for each other at a glance.
.focus-icon
{
height: 26px;
position: relative;
.chev
{
position: absolute;
width: 13px;
height: 4px;
background-color: rgba( 235, 235, 235, 0.9 );
left: 7px;
}
// ⚠️ Two bars rather than two rotated Vs. A rotate would need transform support
// this layout system has only been proven to have for the salvage diamond, and a
// pair of stacked dashes reads as "stacking" perfectly well.
.chev.a { top: 8px; }
.chev.b { top: 15px; width: 9px; left: 9px; }
}
// Vigor Rush Killstreak (M4) ----------------------------------------------
//
// ⚠️ THE BAR ITSELF REUSES `.focus`, because it IS the same object — a streak with a
// multiplier label. Only the icon differs, so two streak bars stacked together stay
// tellable apart. A third copy of the bar rules is how one of them drifts.
.streak-icon
{
height: 26px;
justify-content: center;
align-items: center;
// A solid square against Focus's stacked dashes — the most distinguishable shape
// available without depending on transform support.
.pip
{
width: 13px;
height: 13px;
background-color: rgba( 235, 235, 235, 0.9 );
}
}
// ── PERKS — bottom centre ────────────────────────────────────────────────────
//
// A single centred row that grows outward as perks are bought.
//
// ⚠️ CENTRED BY THE PARENT'S FLEX, and it works here where it did NOT for the
// Wunderfizz panel, the powerup banner or the perk grid. The difference is the
// CHILDREN: those cases centred an ABSOLUTELY POSITIONED child, which opts out
// of the parent's flex entirely. These icons are in normal flow, so
// justify-content applies to them normally.
//
// ⛔ NO NEGATIVE-MARGIN TRICK HERE, deliberately. That fix needs a KNOWN width —
// this row's width changes with every perk bought, so a fixed margin would only
// be centred at one perk count and drift at every other.
.perks
{
position: absolute;
bottom: 24px;
left: 0px;
width: 100%;
flex-direction: row;
align-items: center;
justify-content: center;
.perk
{
width: 44px;
height: 44px;
margin-left: 5px;
margin-right: 5px;
// Sits over gameplay, so it needs to read against a bright floor as well
// as a dark one — a soft dark drop keeps the edges defined either way.
text-shadow: 0px 2px 6px rgba( 0, 0, 0, 0.9 );
}
}
// ── NAPALM IGNITION ──────────────────────────────────────────────────────────
//
// Horizontally centred, sat BELOW the crosshair, and faded out over 2s to match
// the original's `fadefac = (napalmdecay - CurTime()) / 2`.
//
// ⛔ THE FADE IS CSS, NOT C#. Driving opacity from the remaining time would mean
// rebuilding this whole panel every frame the icon is visible — and it is only
// ever visible mid-firefight, when the HUD can least afford it. The panel emits
// the element once and the animation runs itself.
//
// ⚠️ NOT ON THE CROSSHAIR, BY REQUEST. The original draws it dead centre, which
// puts a 64px icon over whatever you are currently shooting at — exactly the
// moment it fires. Half the size and dropped to 66% of screen height: still on
// the vertical axis where the eye already is, clear of the reticle, and above the
// bottom HUD furniture.
//
// ⚠️ THE NEGATIVE MARGINS ARE HALF THE BOX, so they have to move WITH the size.
// They are what actually places the icon — `left`/`top` position its top-left
// corner, and the margins pull it back by half to centre the box on that point.
// Halving the box and leaving them at -32px would have shifted it up and left by
// 16px each, which reads as "the position is also wrong" rather than as a missed
// edit.
.ignite
{
position: absolute;
left: 50%;
top: 66%;
margin-left: -16px;
margin-top: -16px;
width: 32px;
height: 32px;
animation: nz-ignite 2s linear forwards;
img { width: 32px; height: 32px; }
}
// Holds full opacity briefly, then falls away — the original's alpha is
// 300*fadefac against a 255 ceiling, so it is pinned for the first third.
@keyframes nz-ignite
{
0% { opacity: 1; transform: scale( 1.35 ); }
18% { opacity: 1; transform: scale( 1 ); }
100% { opacity: 0; transform: scale( 1 ); }
}
// ⛔ AND THE SAME BUG AGAIN, twenty lines further down: this brace was missing too, so
// `.salvage` was parsed as a keyframe step and the salvage counter has never had a style
// either. Two independent instances of one mistake in one file, both reported by the engine
// at startup and both unread.
// Ammo mod -----------------------------------------------------------------
// Salvage ------------------------------------------------------------------
// Under the ammo, right-aligned with it. Green, matching the outline on the
// salvage pickup itself so the floor loot and the counter read as one system.
.salvage
{
flex-direction: row;
align-items: center;
justify-content: flex-end;
gap: 6px;
.salvage-icon
{
width: 11px;
height: 11px;
background-color: rgba( 60, 210, 90, 0.95 );
// A diamond, so it is distinct from the plate's rectangle at a glance.
transform: rotate( 45deg );
}
.salvage-count
{
font-size: 20px;
font-weight: 700;
color: rgba( 235, 235, 235, 0.95 );
text-shadow: 0px 2px 5px rgba( 0, 0, 0, 0.9 );
}
}
// ═══ BASALT — THE CARVED HUD (`HudTheme`, `MapConfig.HudTheme = "basalt"`) ═══════════════════════════════════════════════════
//
// Asked for as *"geometrical and ancient looking but with modern light strips"*, then *"make it more ancient … ancient with
// accents basically"* and *"make sure everything is in the correct place"* (2026-09-27). Carved stone tablets, bone and ember,
// and a thin white light strip here and there as the only modern thing — the way the map's own strips are the only
// manufactured thing in it.
//
// ⛔ A LOOK, NEVER A LAYOUT — nothing here may move a panel, and `ui_panel_dump` in both looks is how that was checked:
// * NO TABLETS (2026-09-27, later) — *"the background for the hud elements take too much space so i want to remove
// them"*. The stone slabs behind the vitals, the score and the weapon are gone; the grooves, the sockets and the
// colours stay.
// * THE BARS KEEP THEIR 2px BORDERS — only the colours change, so each is still 256 x 18.
// * THE FRAMES ON THE PERKS AND THE TEAM-MATES' FACES ARE BOX-SHADOW RINGS, which take no room: a border would come out of
// the icon inside the box.
// * THE FACE IS CINZEL ON EVERY WORD (2026-09-27, later), on Inter's line box, so no row grows taller — the one thing
// here that can move a word, because it is wider than Inter on mixed-case text: a long weapon name reaches further.
// No size, weight or spacing changes but the Roman round's own.
//
// ⚠️ `.hud.theme-basalt` IN FRONT OF EVERY RULE, so each is more specific than the ordinary rule it overrides — none of it
// depends on coming later in the file. That holds for the rules outside the `.hud` block too (`.charge`, `.focus`, the
// icons, `.salvage`, `.perks`): each is a descendant of the root that wears the class.
$stone: rgba( 46, 42, 37, 0.9 );
$stone-hi: rgba( 92, 83, 72, 1 );
$stone-lo: rgba( 17, 14, 11, 1 );
$groove: rgba( 15, 12, 9, 0.95 );
$groove-lip: rgba( 96, 86, 74, 1 );
$bone: rgba( 228, 217, 195, 1 );
$bone-dim: rgba( 160, 148, 130, 1 );
$ember: rgba( 222, 108, 46, 1 );
$ember-hot: rgba( 248, 150, 72, 1 );
$lite: rgba( 234, 246, 255, 1 );
.hud.theme-basalt
{
// ── the face: the map's, Cinzel, on every word ────────────────────────────────────────────────────────────────────────
// *"i want the font for this map to affect the ammo and weapon name …"* (2026-09-27). On the root, so all of it inherits:
// the score, the weapon's name and ammo, the grenades, the salvage, the team-mates' points (the use prompt names its own
// box, in UsePromptPanel.razor.scss). ⛔ ON INTER'S LINE BOX (0.898, for the reasons `.round` gives below): every row
// here is sized around a line of Inter. Cinzel-Bold is the one face shipped, so every weight draws bold.
font-family: Cinzel;
line-height: 0.898;
// ── the bars: grooves cut into the stone ─────────────────────────────────────────────────────────────────────────────
.health, .stamina, .place-charge, .charge, .focus
{
background-color: $groove;
border: 2px solid $stone-lo;
border-right-color: $groove-lip;
border-bottom-color: $groove-lip;
}
// health: molten ember, the heat under the rock
.health .fill { background-color: rgba( 196, 86, 34, 1 ); }
.health.low .fill { background-color: rgba( 246, 72, 32, 1 ); }
// stamina: weathered bone
.stamina .fill { background-color: rgba( 176, 165, 146, 0.95 ); }
.stamina.low .fill { background-color: $ember; }
// the banana's charge: old gold
.place-charge .fill { background-color: rgba( 204, 166, 92, 0.95 ); }
.place-charge.ready { border-color: $ember-hot; }
.place-charge.ready .fill { background-color: rgba( 242, 206, 132, 1 ); }
.place-charge.paused { border-color: rgba( 255, 255, 255, 0.12 ); }
.place-charge.paused .fill { background-color: rgba( 204, 166, 92, 0.3 ); }
// armor: slate
.armor .seg { background-color: $groove; }
.armor .seg.unlocked
{
border: 2px solid $stone-lo;
border-right-color: $groove-lip;
border-bottom-color: $groove-lip;
}
.armor .seg.locked
{
border: 2px solid rgba( 70, 63, 55, 0.55 );
background-color: rgba( 15, 12, 9, 0.5 );
}
.armor .fill { background-color: rgba( 132, 148, 152, 0.95 ); }
.plates .plate-icon { background-color: rgba( 132, 148, 152, 0.95 ); }
.plates .plate-count { color: $bone; }
.plates.empty .plate-icon { background-color: rgba( 92, 83, 72, 0.6 ); }
.plates.empty .plate-count { color: $bone-dim; }
// the icons, in bone
.hp-icon { color: $bone; }
.boot .shaft, .boot .foot, .armor-icon .shield, .charge-icon .dot, .focus-icon .chev, .streak-icon .pip
{
background-color: $bone;
}
.charge-icon .ring { border-color: $bone; }
.banana .peel { background-color: rgba( 222, 190, 110, 0.95 ); }
// ── the score: old gold ──────────────────────────────────────────────────────────────────────────────────────────────
.points .value { color: rgba( 226, 204, 158, 1 ); }
.points .portrait
{
background-color: $groove;
border-color: $stone-hi;
border-right-color: $stone-lo;
border-bottom-color: $stone-lo;
}
.mate .portrait { box-shadow: 0px 0px 0px 1px $stone-hi; }
.mate .pts { color: rgba( 226, 204, 158, 0.9 ); }
// ── the round: carved ember numerals, in Cinzel ──────────────────────────────────────────────────────────────────────
// Cinzel is Roman inscription capitals (`Assets/fonts/Cinzel-Bold.ttf`, SIL OFL 1.1, with `Cinzel-OFL.txt` beside it). The
// user picked it from an in-game preview (2026-09-27). It is the one weight, Bold, so every round rule here asks for 700.
//
// ⛔ ITS LINE BOX IS PINNED TO INTER'S: 1.211 of the size, where Cinzel's own is 1.348. `.lower` is as tall as its tallest
// child, which is normally the weapon (90px). At 72px Cinzel's own line (98px) would outgrow it, and the row would lift
// the whole right-hand column, the score included, about 7px through every round from 10. Pinned, the round's box is
// exactly the ordinary HUD's at every size, so nothing around it can move.
//
// ⛔ AND THE PIN IS 0.898, NOT 1.211 (corrected 2026-09-27, later). s&box reads a unitless or percentage line-height as a
// share of the FONT'S OWN line, not of its size, so 1.211 made this 1.211 x 1.348 = 1.63 of the size: a 72px round 118px
// tall where Inter's is 88. 0.898 is 1.211 / 1.348. `text_measure` shows it (Cinzel 72px: 118 at 1.211, 88 at 0.898;
// Inter, 88), and so did the Arsenal's 47px title in the running game: 78px at 1.211, where Inter's is 58.
.round
{
font-family: Cinzel;
font-weight: 700;
line-height: 0.898;
color: $ember;
text-shadow: 0px 2px 4px rgba( 0, 0, 0, 1 );
}
.round.prep { color: $bone; }
// ⚠️ ROMAN FOR ROUNDS I TO IX ONLY (`HudTheme.RomanUpTo`) — "VIII" is the longest, four glyphs — so the ordinary tally's
// own 64px. From round 10 the digits take the ordinary `.digits` rule, in ember.
.round.roman
{
font-size: 64px;
font-weight: 700;
letter-spacing: 4px;
}
// ── the weapon: bone numbers ─────────────────────────────────────────────────────────────────────────────────────────
.weapon .ammo .clip { color: $bone; }
.weapon .ammo .sep { color: $bone-dim; }
.weapon .ammo .reserve { color: $bone-dim; }
.weapon .reloading { color: $ember-hot; }
.weapon .ammo .techtag { color: $bone-dim; }
.weapon .ammo .techtag.flash { color: $bone; }
.weapon .ammo .techtag.ready { color: $ember-hot; }
// ⚠️ THE GRENADES ARE THE WEAPON'S SIBLING in the markup (in `.lower`), not its child — so these are not nested under it
.grenades .nade-icon { color: $ember; }
.grenades .nade-count { color: $bone; }
.grenades.cooking .nade-icon, .grenades.cooking .nade-count { color: rgba( 255, 80, 50, 1 ); }
// salvage: jade
.salvage .salvage-icon { background-color: rgba( 96, 170, 120, 0.95 ); }
.salvage .salvage-count { color: $bone; }
// ── the perks, each set in a stone socket ────────────────────────────────────────────────────────────────────────────
.perks .perk
{
background-color: $stone;
box-shadow: 0px 0px 0px 2px $stone-hi;
}
}