Files
OBS-overlay/docs/plans/2026-03-20-header-meter-implementation.md
2026-03-20 14:57:28 -04:00

11 KiB

Header Text Player Meter — Implementation Plan

For Claude: REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.

Goal: Add a gradient fill meter to the "ROOM CODE:" header that visually indicates lobby fill (playerCount / maxPlayers), animating left-to-right from the header color (pink) to white, with a pulse effect at 100%.

Architecture: The existing RoomCodeDisplay component gains meter state tracking and a requestAnimationFrame interpolation loop. CSS background-clip: text with a dynamic linear-gradient replaces the flat header.style.color. The OverlayManager already broadcasts playerCount and maxPlayers in context — no state management changes needed.

Tech Stack: Vanilla JS (ES modules), CSS background-clip: text, requestAnimationFrame.


Task 1: Revert Player List Default to Unchecked

Files:

  • Modify: optimized-controls.html:669 (the player-list-enabled checkbox)

Step 1: Remove the checked attribute

In optimized-controls.html, change:

<input type="checkbox" id="player-list-enabled" checked>

to:

<input type="checkbox" id="player-list-enabled">

Step 2: Verify in browser

Open http://localhost:8080/optimized-controls.html, expand Player List Settings, confirm the checkbox is unchecked by default.

Step 3: Commit

git add optimized-controls.html
git commit -m "fix: disable player list by default"

Task 2: Update #header CSS for Gradient Text Support

Files:

  • Modify: optimized-controls.html<style> block, #header rule (lines ~30-42)

Step 1: Replace #header CSS

Find the existing #header rule:

#header {
    position: absolute;
    width: 100%;
    text-align: center;
    transform: translateY(-220px);
    color: #f35dcb;
    font-size: 80px;
    font-weight: bold;
    text-shadow: 3px 3px 8px rgba(0, 0, 0, 0.8);
    letter-spacing: 2px;
    opacity: 0;
    transition: opacity 0.5s ease;
}

Replace with:

#header {
    position: absolute;
    width: 100%;
    text-align: center;
    transform: translateY(-220px);
    color: #f35dcb;
    font-size: 80px;
    font-weight: bold;
    letter-spacing: 2px;
    opacity: 0;
    transition: opacity 0.5s ease;
    /* Meter gradient support */
    background: linear-gradient(to right, #ffffff 0%, #ffffff 0%, #f35dcb 0%, #f35dcb 100%);
    -webkit-background-clip: text;
    background-clip: text;
    -webkit-text-fill-color: transparent;
    filter: drop-shadow(3px 3px 8px rgba(0, 0, 0, 0.8));
}

Key changes: removed text-shadow (incompatible with transparent text), added background gradient, background-clip: text, -webkit-text-fill-color: transparent, and filter: drop-shadow() to replace the shadow.

Step 2: Add pulse keyframes and class

After the #header rule, add:

@keyframes meter-full-pulse {
    0%   { filter: drop-shadow(3px 3px 8px rgba(0,0,0,0.8)); }
    50%  { filter: drop-shadow(0 0 20px rgba(255,255,255,0.6)) drop-shadow(3px 3px 8px rgba(0,0,0,0.8)); }
    100% { filter: drop-shadow(3px 3px 8px rgba(0,0,0,0.8)); }
}

#header.meter-full-pulse {
    animation: meter-full-pulse 0.6s ease-out;
}

Step 3: Verify in browser

Load the page. The header text should still appear pink during the animation cycle but now rendered via gradient clip instead of flat color. The shadow should look the same (via drop-shadow).

Step 4: Commit

git add optimized-controls.html
git commit -m "feat: add CSS gradient-clip and pulse keyframes for header meter"

Task 3: Add Meter State and Gradient Logic to RoomCodeDisplay

Files:

  • Modify: js/room-code-display.js

Step 1: Add private meter fields

After the existing private fields (#elements, #inputs, #animationTimers, #active), add:

/** @type {number} Current fill 0-1 */
#meterFill = 0;

/** @type {number} Target fill 0-1 */
#meterTarget = 0;

/** @type {number | null} */
#meterRafId = null;

/** @type {boolean} Whether the pulse has fired for the current 100% state */
#meterPulseFired = false;

Step 2: Add #applyMeterGradient() method

This reads the current #meterFill and the configured header color, then sets the gradient on the header element.

#applyMeterGradient() {
    const header = this.#elements?.header;
    const inputs = this.#inputs;
    if (!header || !inputs) return;

    const pct = Math.round(this.#meterFill * 100);
    const baseColor = inputs.headerColor.value || '#f35dcb';

    header.style.background =
        `linear-gradient(to right, #ffffff 0%, #ffffff ${pct}%, ${baseColor} ${pct}%, ${baseColor} 100%)`;
    header.style.webkitBackgroundClip = 'text';
    header.style.backgroundClip = 'text';
    header.style.webkitTextFillColor = 'transparent';
}

Step 3: Add #animateMeterTo(target) method

Smooth requestAnimationFrame interpolation from current fill to target over ~400ms.

#animateMeterTo(target) {
    const clamped = Math.max(0, Math.min(1, target));
    this.#meterTarget = clamped;

    if (this.#meterRafId != null) {
        cancelAnimationFrame(this.#meterRafId);
        this.#meterRafId = null;
    }

    const start = this.#meterFill;
    const delta = clamped - start;
    if (Math.abs(delta) < 0.001) {
        this.#meterFill = clamped;
        this.#applyMeterGradient();
        this.#checkFullPulse();
        return;
    }

    const duration = 400;
    const startTime = performance.now();

    const step = (now) => {
        const elapsed = now - startTime;
        const t = Math.min(elapsed / duration, 1);
        const eased = 1 - Math.pow(1 - t, 3); // ease-out cubic
        this.#meterFill = start + delta * eased;
        this.#applyMeterGradient();

        if (t < 1) {
            this.#meterRafId = requestAnimationFrame(step);
        } else {
            this.#meterRafId = null;
            this.#meterFill = clamped;
            this.#applyMeterGradient();
            this.#checkFullPulse();
        }
    };

    this.#meterRafId = requestAnimationFrame(step);
}

Step 4: Add #checkFullPulse() method

#checkFullPulse() {
    const header = this.#elements?.header;
    if (!header) return;

    if (this.#meterFill >= 1 && !this.#meterPulseFired) {
        this.#meterPulseFired = true;
        header.classList.add('meter-full-pulse');
        header.addEventListener('animationend', () => {
            header.classList.remove('meter-full-pulse');
        }, { once: true });
    }

    if (this.#meterFill < 1) {
        this.#meterPulseFired = false;
    }
}

Step 5: Modify #applySettings() — replace color line with gradient

In the existing #applySettings() method, find:

header.style.color = inputs.headerColor.value;

Replace with:

this.#applyMeterGradient();

This ensures that whenever settings are applied, the gradient is re-rendered with the correct header color and current fill level.

Step 6: Modify update(ctx) — add meter update logic

The existing update() only checks roomCode changes. After the existing roomCode check, add meter logic:

// After the existing roomCode change check:
const count = Number(ctx?.playerCount ?? 0);
const max = Number(ctx?.maxPlayers ?? 0);
const newTarget = max > 0 ? count / max : 0;

if (Math.abs(newTarget - this.#meterTarget) > 0.001) {
    this.#animateMeterTo(newTarget);
}

Step 7: Modify deactivate() — cancel RAF and reset meter

Add before the existing opacity fade-out code:

if (this.#meterRafId != null) {
    cancelAnimationFrame(this.#meterRafId);
    this.#meterRafId = null;
}
this.#meterFill = 0;
this.#meterTarget = 0;
this.#meterPulseFired = false;

Step 8: Modify activate(ctx) — seed initial meter from context

After the existing this.#applySettings() call, add:

const count = Number(ctx?.playerCount ?? 0);
const max = Number(ctx?.maxPlayers ?? 0);
const initialFill = max > 0 ? count / max : 0;
this.#meterFill = Math.max(0, Math.min(1, initialFill));
this.#meterTarget = this.#meterFill;
this.#meterPulseFired = false;
this.#applyMeterGradient();

Step 9: Update getStatus() — include meter info

Add meterFill to the returned object:

getStatus() {
    // ... existing code ...
    return {
        active: this.#active,
        roomCode,
        timersRunning: this.#animationTimers.length > 0,
        meterFill: Math.round(this.#meterFill * 100),
    };
}

Step 10: Verify in browser

  1. Open the overlay, connect to API.
  2. Trigger a game (or use Update/Preview button).
  3. The header should appear 100% pink (0 players).
  4. As players join, the gradient should animate left-to-right toward white.
  5. At full capacity, a brief pulse/glow should fire.

Step 11: Commit

git add js/room-code-display.js
git commit -m "feat: add animated gradient meter to header text based on player count"

Task 4: Update Dashboard Status for Meter

Files:

  • Modify: js/controls.jsformatStatusRow function

Step 1: Include meter fill in roomCode status display

In formatStatusRow, find the roomCode case:

if (name === 'roomCode') {
    const rc = /** @type {{ active?: boolean, roomCode?: string }} */ (s);
    return rc.active ? `Active (${rc.roomCode ?? ''})` : 'Inactive';
}

Change to:

if (name === 'roomCode') {
    const rc = /** @type {{ active?: boolean, roomCode?: string, meterFill?: number }} */ (s);
    if (!rc.active) return 'Inactive';
    const meter = rc.meterFill != null ? ` | Meter: ${rc.meterFill}%` : '';
    return `Active (${rc.roomCode ?? ''})${meter}`;
}

Step 2: Commit

git add js/controls.js
git commit -m "feat: show meter fill percentage in dashboard status"

Task 5: End-to-End Verification

Step 1: Full flow test

  1. Load http://localhost:8080/optimized-controls.html.
  2. Confirm player list checkbox is unchecked by default.
  3. Connect to the API WebSocket.
  4. Trigger a game via the Game Picker (or use Preview/Update button with a room code).
  5. Observe: header appears 100% pink.
  6. As players join, gradient fills left-to-right, animating smoothly.
  7. When lobby is full, text is 100% white and pulse fires.
  8. When game starts/ends, header deactivates, meter resets.
  9. New lobby → header starts fresh at 0% fill.

Step 2: Edge case test

  • Use Update/Preview with max_players: 2. Join 1 player (50%), then 2nd (100% + pulse).
  • Disconnect/reconnect mid-lobby — meter should reset and re-seed from context.

Step 3: Final commit (if any polish needed)

git add -A
git commit -m "polish: header meter edge cases and cleanup"