GLaDOS-AI-Animation - A card synchs voice assistant to animated GLaDOS

I was looking forever for a way to have a animated avatar that indicates what the local assistant is doing. Decided to take the plunge and make it myself:

GLaDOS Lovelace Card for Home Assistant

Bring the Aperture Science Enrichment Center to your smart home. This custom Lovelace card provides a fully animated, responsive GLaDOS interface that syncs directly with your Home Assistant Voice Assistant or Assist Satellite entities.

Built entirely with scalable vector graphics (SVG) and native JavaScript, she reacts in real-time to your voice assistant’s state, shifting colors, moving her chassis, and staring at you with that familiar, judgmental gaze.

Features

  • Native Home Assistant Integration: No tokens or websockets required; it uses native Lovelace state tracking.

  • Cinematic Animations: Smooth, curve-based animations for breathing, tracking, and talking, mimicking her movements from Portal 1 & Portal 2.

  • Dynamic Personality: When idle, a weighted behavioral system randomly triggers different movements (scanning the room, showing curiosity, or getting bored).

  • Configurable: Easily adjust the zoom level and the speed of her erratic eye tracking directly from your dashboard YAML.

System States

GLaDOS tracks the state of your voice assistant entity and visually responds accordingly:

:yellow_circle: Standby Mode (Idle)

  • Trigger: The assistant is waiting for the wake word.

  • Visuals: Her optic glows in the canonical Aperture Science Yellow/Orange. Her mechanical eyelids are open, and her chassis sways gently.

  • Behavior: She will randomly look around the room, dart her eye, or execute specific idle animations (like snapping to attention or sighing in boredom).

:large_blue_circle: Aural Receptors Active (Listening)

  • Trigger: The wake word is detected and she is actively listening.

  • Visuals: Her optic and matrix LEDs snap to a bright Aperture Blue.

  • Movement: She snaps her head forward, leans in slightly, and narrows her eyelids to focus directly on you.

:orange_circle: Computing Databanks (Processing)

  • Trigger: You finish speaking, and the system is transcribing/processing your request.

  • Visuals: Her optic shifts to a deep, burning orange. Blue processing dots light up above her faceplate, and an orange scanning ring materializes.

  • Movement: She tilts her head downward and squints heavily, with her eyelids twitching erratically to simulate her internal processors working overtime.

:red_circle: Vocalizing (Responding)

  • Trigger: The system is playing back the Text-to-Speech (TTS) response.

  • Visuals: Her optic shifts to a hostile, glowing red, accompanied by a flashing danger ring.

  • Movement: Because she lacks a mouth, she speaks through gesture. She executes smooth, cinematic sweeps mixed with sharp, bird-like pecks and nods to match the cadence of her speech.

Installation

Via HACS (Home Assistant Community Store)

  1. Open HACS in Home Assistant.

  2. Click on the 3 dots in the top right corner and select Custom repositories.

  3. Add the URL to this repository and select Lovelace as the category.

  4. Click Install.

  5. When prompted, reload your browser resources.

Configuration

Add the card to your dashboard via the Lovelace UI by adding a manual card, or by editing your dashboard YAML:

type: custom:glados-card
# REQUIRED: The entity ID of your voice assistant or assist satellite
entity: assist_satellite.living_room
# OPTIONAL: Scale the size of the model (Default: 85)
zoom: 85
# OPTIONAL: How fast her eye darts around during idle. 0 stops it completely. 100 is highly agitated. (Default: 50)
eye_speed: 50

Note: “Aperture Science is not responsible for any sarcastic remarks or sudden urges to test made by your smart home system.”

Disclosure, most of this was made with AI (Claude until the limit became to much, the rest on Gemini)

If you save following as a .html then you will have the preview/debug version

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>GLaDOS Preview</title>
  <style>
    body {
      display: flex;
      flex-direction: column;
      align-items: center;
      justify-content: center;
      background: #000000;
      min-height: 100vh;
      margin: 0;
      padding: 0;
      overflow: hidden;
      font-family: monospace;
    }
    
    * { box-sizing: border-box; }
    
    #scene {
      display: flex; 
      align-items: center;
      justify-content: center;
    }
    
    #glados-svg { 
      width: 100%;
      height: 100%;
      display: block; 
      overflow: visible; 
    }
    
    #body-pivot { transform-origin: 140px 116px; animation: body-sway 8s ease-in-out infinite; }
    @keyframes body-sway {
      0%   { transform: rotate(-1.4deg); }
      50%  { transform: rotate( 1.4deg); }
      100% { transform: rotate(-1.4deg); }
    }
    
    #head-sway-pivot { transform-origin: 140px 285px; animation: head-ambient-sway 13s ease-in-out infinite; }
    @keyframes head-ambient-sway {
      0%, 100% { transform: rotate(-0.8deg); }
      50%      { transform: rotate(0.8deg); }
    }

    #glados-head {
      transform-box: view-box; transform-origin: 140px 285px;
      transition: transform 1.6s cubic-bezier(0.34, 1.06, 0.64, 1);
    }

    #eye-halo, #eye-center { transition: fill 0.8s ease-in-out; }
    .eye-layer { transition: opacity 0.8s ease-in-out; }
    
    @keyframes eye-breathe { 0%,100%{opacity:.02} 48%{opacity:.2} }
    #eye-halo.breathing { animation: eye-breathe 8s ease-in-out infinite; }
    
    @keyframes danger-flash { 0%,100%{opacity:0} 50%{opacity:1} }
    #danger-ring.active { animation: danger-flash .35s ease-in-out infinite; }

    /* Pulsing Vents animation specifically for processing state */
    @keyframes led-pulse { 0%,100%{opacity:0.2} 50%{opacity:1} }
    .led-matrix.pulsing .led-dot { animation: led-pulse 2s ease-in-out infinite; }

    /* UI Controls for Standalone Testing */
    .controls {
      margin-top: 40px;
      display: flex;
      gap: 10px;
      z-index: 10;
    }
    .controls button {
      background: #1a1a24;
      border: 1px solid #334;
      color: #889;
      padding: 8px 16px;
      border-radius: 4px;
      cursor: pointer;
      font-family: inherit;
      transition: all 0.2s;
    }
    .controls button:hover {
      background: #2a2a36;
      color: #fff;
      border-color: #556;
    }
    .controls button.active {
      border-color: #ffb800;
      color: #ffb800;
    }
  </style>
</head>
<body>

  <div id="scene">
    <svg id="glados-svg" viewBox="0 116 280 320">
      <defs>
        <!-- Sharp color stops added to indicate the 3D sloped bevels on the sides -->
        <linearGradient id="ceramicGrad" x1="0" y1="0" x2="1" y2="0">
          <stop offset="0%" stop-color="#8a8d94"/>
          <stop offset="8%" stop-color="#b0b4bc"/>
          <stop offset="8.5%" stop-color="#ffffff"/> <!-- Sharp left edge -->
          <stop offset="25%" stop-color="#ffffff"/>
          <stop offset="75%" stop-color="#ffffff"/>
          <stop offset="91.5%" stop-color="#e8eaec"/> <!-- Sharp right edge -->
          <stop offset="92%" stop-color="#a0a4ac"/>
          <stop offset="100%" stop-color="#6a6d75"/>
        </linearGradient>
        
        <linearGradient id="ceramicShadow" x1="0" y1="0" x2="0" y2="1">
          <stop offset="0%" stop-color="#ffffff" stop-opacity="0"/>
          <stop offset="60%" stop-color="#60646c" stop-opacity="0.1"/>
          <stop offset="85%" stop-color="#2a2c32" stop-opacity="0.5"/>
          <stop offset="100%" stop-color="#0a0a0f" stop-opacity="0.85"/>
        </linearGradient>

        <linearGradient id="bezelGrad" x1="0" y1="0" x2="1" y2="0">
          <stop offset="0%" stop-color="#4a4d54"/>
          <stop offset="20%" stop-color="#6a6d75"/>
          <stop offset="50%" stop-color="#3a3c42"/>
          <stop offset="80%" stop-color="#1a1c20"/>
          <stop offset="100%" stop-color="#0a0a0c"/>
        </linearGradient>

        <linearGradient id="ceramicGradDark" x1="0" y1="0" x2="1" y2="0">
          <stop offset="0%" stop-color="#8a8e96"/>
          <stop offset="15%" stop-color="#b0b4bc"/>
          <stop offset="50%" stop-color="#d0d4dc"/>
          <stop offset="85%" stop-color="#8a8e96"/>
          <stop offset="100%" stop-color="#555a62"/>
        </linearGradient>
        
        <linearGradient id="armorVDark" x1="0" y1="0" x2="0" y2="1">
          <stop offset="0%" stop-color="#14141a"/>
          <stop offset="50%" stop-color="#09090c"/>
          <stop offset="100%" stop-color="#040406"/>
        </linearGradient>
        
        <linearGradient id="cavityGrad" x1="0" y1="0" x2="0" y2="1">
          <stop offset="0%" stop-color="#181a1c"/>
          <stop offset="100%" stop-color="#30353a"/>
        </linearGradient>
        
        <linearGradient id="trackGrad" x1="0" y1="0" x2="1" y2="0">
          <stop offset="0%" stop-color="#1a1c20"/>
          <stop offset="50%" stop-color="#3a3e46"/>
          <stop offset="100%" stop-color="#121316"/>
        </linearGradient>
        
        <radialGradient id="eyeGradIdle" cx="50%" cy="50%" r="50%">
          <stop offset="0%" stop-color="#ffffff"/>
          <stop offset="20%" stop-color="#ffcc00"/>
          <stop offset="55%" stop-color="#d95500"/>
          <stop offset="80%" stop-color="#7a1100"/>
          <stop offset="100%" stop-color="#110000"/>
        </radialGradient>
        <radialGradient id="eyeGradListen" cx="50%" cy="50%" r="50%">
          <stop offset="0%" stop-color="#ffffff"/>
          <stop offset="25%" stop-color="#aaffff"/>
          <stop offset="60%" stop-color="#00ccff"/>
          <stop offset="85%" stop-color="#0066aa"/>
          <stop offset="100%" stop-color="#001a33"/>
        </radialGradient>
        <radialGradient id="eyeGradProcess" cx="50%" cy="50%" r="50%">
          <stop offset="0%" stop-color="#ffffff"/>
          <stop offset="25%" stop-color="#ffddaa"/>
          <stop offset="60%" stop-color="#ff6600"/>
          <stop offset="85%" stop-color="#aa3300"/>
          <stop offset="100%" stop-color="#220a00"/>
        </radialGradient>
        <radialGradient id="eyeGradRespond" cx="50%" cy="50%" r="50%">
          <stop offset="0%" stop-color="#ffffff"/>
          <stop offset="25%" stop-color="#ffaaaa"/>
          <stop offset="60%" stop-color="#ff2200"/>
          <stop offset="85%" stop-color="#aa0000"/>
          <stop offset="100%" stop-color="#220000"/>
        </radialGradient>

        <filter id="eyeBloom" x="-120%" y="-120%" width="340%" height="340%">
          <feGaussianBlur stdDeviation="10" result="b"/>
          <feMerge><feMergeNode in="b"/><feMergeNode in="SourceGraphic"/></feMerge>
        </filter>
        <filter id="softGlow" x="-30%" y="-30%" width="160%" height="160%">
          <feGaussianBlur stdDeviation="2" result="b"/>
          <feMerge><feMergeNode in="b"/><feMergeNode in="SourceGraphic"/></feMerge>
        </filter>
        <filter id="ledGlow" x="-60%" y="-60%" width="220%" height="220%">
          <feGaussianBlur stdDeviation="2.5" result="b"/>
          <feMerge><feMergeNode in="b"/><feMergeNode in="SourceGraphic"/></feMerge>
        </filter>
        <linearGradient id="lidGrad" x1="0" y1="0" x2="0" y2="1">
          <stop offset="0%" stop-color="#1f2124"/>
          <stop offset="100%" stop-color="#08090a"/>
        </linearGradient>
        <linearGradient id="lidGradFlip" x1="0" y1="1" x2="0" y2="0">
          <stop offset="0%" stop-color="#1f2124"/>
          <stop offset="100%" stop-color="#08090a"/>
        </linearGradient>

        <clipPath id="cavityClip">
           <rect x="97" y="269" width="66" height="190" rx="33"/>
        </clipPath>
        <clipPath id="trackClip">
           <rect x="107" y="279" width="46" height="170" rx="23"/>
        </clipPath>
        <clipPath id="eyeballClip">
           <circle cx="130" cy="364" r="25.5"/>
        </clipPath>
      </defs>

      <g id="body-pivot">
        <g id="torso" transform="translate(140, 116) scale(1.2) translate(-140, -116)">
          <ellipse cx="140" cy="116" rx="55" ry="15" fill="#1c1c26" stroke="#0c0c12" stroke-width="1.2"/>
          <ellipse cx="140" cy="116" rx="46" ry="11" fill="#141420" stroke="#1e1e2c" stroke-width=".7"/>
          <path d="M95 130 L92 138 L90 200 Q90 206 97 208 L183 208 Q190 206 190 200 L188 138 L185 130 Z" fill="url(#armorVDark)" stroke="#111115" stroke-width=".8"/>
          <path d="M94 126 L86 134 L84 200 Q84 210 94 212 L186 212 Q196 210 196 200 L194 134 L186 126 Z" fill="url(#ceramicGradDark)" stroke="#80848c" stroke-width="1.4"/>
          <path d="M90 132 L62 140 L58 180 L62 196 L74 200 L90 196 Z" fill="url(#ceramicGradDark)" stroke="#80848c" stroke-width="1"/>
          <path d="M90 136 L66 143 L62 178 L66 192 L76 196 L90 192 Z" fill="#eeeeee" opacity="0.05"/>
          <circle cx="60" cy="168" r="9" fill="#14141c" stroke="#0c0c12" stroke-width="1"/>
          <circle cx="60" cy="168" r="5.5" fill="#0c0c10" stroke="#1a1a22" stroke-width=".8"/>
          <path d="M90 132 C86 152 84 172 86 192" stroke="#1a1a22" stroke-width="2.5" fill="none" opacity=".8"/>
          <path d="M190 132 L218 140 L222 180 L218 196 L206 200 L190 196 Z" fill="url(#ceramicGradDark)" stroke="#80848c" stroke-width="1"/>
          <path d="M190 136 L214 143 L218 178 L214 192 L204 196 L190 192 Z" fill="#eeeeee" opacity="0.05"/>
          <circle cx="220" cy="168" r="9" fill="#14141c" stroke="#0c0c12" stroke-width="1"/>
          <circle cx="220" cy="168" r="5.5" fill="#0c0c10" stroke="#1a1a22" stroke-width=".8"/>
          <path d="M190 132 C194 152 196 172 194 192" stroke="#1a1a22" stroke-width="2.5" fill="none" opacity=".8"/>
          <line x1="90" y1="152" x2="190" y2="152" stroke="#a0a4ac" stroke-width="1"/>
          <line x1="89" y1="174" x2="191" y2="174" stroke="#a0a4ac" stroke-width="1"/>
          <line x1="140" y1="128" x2="140" y2="210" stroke="#a0a4ac" stroke-width="1"/>
          <rect x="94" y="135" width="36" height="20" rx="2.5" fill="#050508" stroke="#101014" stroke-width=".6"/>
          <rect x="96" y="137" width="32" height="16" rx="1.5" fill="#020202"/>
          <g id="led-matrix-left" class="led-matrix" filter="url(#ledGlow)">
            <rect class="led-dot" x="98" y="140" width="28" height="2" rx="1" fill="#ffb800" opacity=".2"/>
            <rect class="led-dot" x="98" y="145" width="28" height="2" rx="1" fill="#ffb800" opacity=".2"/>
            <rect class="led-dot" x="98" y="150" width="28" height="2" rx="1" fill="#ffb800" opacity=".2"/>
          </g>
          <rect x="150" y="135" width="36" height="20" rx="2.5" fill="#050508" stroke="#101014" stroke-width=".6"/>
          <rect x="152" y="137" width="32" height="16" rx="1.5" fill="#020202"/>
          <g id="led-matrix-right" class="led-matrix" filter="url(#ledGlow)">
            <rect class="led-dot" x="154" y="140" width="28" height="2" rx="1" fill="#ffb800" opacity=".2"/>
            <rect class="led-dot" x="154" y="145" width="28" height="2" rx="1" fill="#ffb800" opacity=".2"/>
            <rect class="led-dot" x="154" y="150" width="28" height="2" rx="1" fill="#ffb800" opacity=".2"/>
          </g>
          <circle cx="100" cy="180" r="2.5" fill="#0a0a0e" stroke="#101014" stroke-width=".5"/>
          <circle id="ind-l1" cx="100" cy="180" r="1.5" fill="#ffb800" opacity=".2"/>
          <circle cx="108" cy="180" r="2.5" fill="#0a0a0e" stroke="#101014" stroke-width=".5"/>
          <circle id="ind-l2" cx="108" cy="180" r="1.5" fill="#ffb800" opacity=".2"/>
          <circle cx="172" cy="180" r="2.5" fill="#0a0a0e" stroke="#101014" stroke-width=".5"/>
          <circle id="ind-r1" cx="172" cy="180" r="1.5" fill="#ffb800" opacity=".2"/>
          <circle cx="180" cy="180" r="2.5" fill="#0a0a0e" stroke="#101014" stroke-width=".5"/>
          <circle id="ind-r2" cx="180" cy="180" r="1.5" fill="#ffb800" opacity=".2"/>
          <path d="M88 208 L90 224 Q140 240 190 224 L192 208 Z" fill="#0a0a0e" stroke="#050508" stroke-width=".9"/>
          <rect x="118" y="232" width="44" height="20" rx="5" fill="#101014" stroke="#08080c" stroke-width="1"/>
          <rect x="120" y="234" width="40" height="16" rx="4" fill="#08080a"/>
          <ellipse cx="140" cy="252" rx="18" ry="5" fill="#0a0a0e" stroke="#08080c" stroke-width=".7"/>
          <path d="M118" y="234" C110 240 108 248 111 256" stroke="#0a0a0f" stroke-width="5" fill="none" stroke-linecap="round"/>
          <path d="M162 234 C170 240 172 248 169 256" stroke="#0a0a0f" stroke-width="5" fill="none" stroke-linecap="round"/>
          <rect x="122" y="256" width="36" height="18" rx="4" fill="#101014" stroke="#08080c" stroke-width=".9"/>
          <rect x="124" y="258" width="32" height="14" rx="3" fill="#08080a"/>
          <ellipse cx="140" cy="274" rx="15" ry="4.5" fill="#0a0a0e" stroke="#08080c" stroke-width=".6"/>
          <path d="M122 258 C114 264 112 270 115 277" stroke="#0a0a0f" stroke-width="4.5" fill="none" stroke-linecap="round"/>
          <path d="M158 258 C166 264 168 270 165 277" stroke="#0a0a0f" stroke-width="4.5" fill="none" stroke-linecap="round"/>
          <rect x="126" y="278" width="28" height="16" rx="3.5" fill="#101014" stroke="#08080c" stroke-width=".8"/>
          <rect x="128" y="280" width="24" height="12" rx="2.5" fill="#08080a"/>
          <ellipse cx="140" cy="294" rx="12" ry="4" fill="#0a0a0e" stroke="#08080c" stroke-width=".6"/>
          <rect x="130" y="297" width="20" height="13" rx="3" fill="#101014" stroke="#08080c" stroke-width=".8"/>
          <rect x="132" y="299" width="16" height="9" rx="2" fill="#08080a"/>
        </g>
      </g>

      <g id="glados-head-wrapper" transform="translate(0, -65)">
        <g id="head-sway-pivot">
          <g id="glados-head">
            <ellipse cx="140" cy="285" rx="18" ry="6" fill="#181824" stroke="#0a0a0f" stroke-width="1"/>
            <ellipse cx="140" cy="285" rx="12" ry="3.8" fill="#101015" stroke="#181824" stroke-width=".6"/>
            
            <rect x="75" y="232" width="130" height="247" rx="60" fill="url(#ceramicGrad)"/>
            <rect x="75" y="232" width="130" height="247" rx="60" fill="url(#ceramicShadow)"/>
            
            <rect x="93" y="265" width="76" height="200" rx="38" fill="#000" opacity="0.6" filter="url(#softGlow)"/>
            <rect x="91" y="263" width="78" height="202" rx="39" fill="url(#bezelGrad)" stroke="#1a1c22" stroke-width="1"/>
            <rect x="93" y="265" width="74" height="198" rx="37" fill="none" stroke="#6a6d75" stroke-width="1.5"/>

            <g clip-path="url(#cavityClip)">
               <rect x="97" y="269" width="66" height="190" rx="33" fill="url(#cavityGrad)"/>
               <rect x="97" y="269" width="66" height="190" rx="33" fill="none" stroke="#050607" stroke-width="5" opacity="0.9"/>
               
               <rect x="107" y="279" width="46" height="170" rx="23" fill="url(#trackGrad)" stroke="#000000" stroke-width="3"/>
               
               <g clip-path="url(#trackClip)">
                  <g id="bellows" style="transition: transform 0.15s ease-out;">
                     <g stroke="#000" stroke-width="4.5" stroke-linecap="butt" opacity="0.9">
                        <line x1="107" y1="140" x2="153" y2="140" /><line x1="107" y1="152" x2="153" y2="152" /><line x1="107" y1="164" x2="153" y2="164" /><line x1="107" y1="176" x2="153" y2="176" /><line x1="107" y1="188" x2="153" y2="188" /><line x1="107" y1="200" x2="153" y2="200" /><line x1="107" y1="212" x2="153" y2="212" /><line x1="107" y1="224" x2="153" y2="224" /><line x1="107" y1="236" x2="153" y2="236" /><line x1="107" y1="248" x2="153" y2="248" /><line x1="107" y1="260" x2="153" y2="260" /><line x1="107" y1="272" x2="153" y2="272" /><line x1="107" y1="284" x2="153" y2="284" /><line x1="107" y1="296" x2="153" y2="296" /><line x1="107" y1="308" x2="153" y2="308" /><line x1="107" y1="320" x2="153" y2="320" /><line x1="107" y1="332" x2="153" y2="332" /><line x1="107" y1="344" x2="153" y2="344" /><line x1="107" y1="356" x2="153" y2="356" /><line x1="107" y1="368" x2="153" y2="368" /><line x1="107" y1="380" x2="153" y2="380" /><line x1="107" y1="392" x2="153" y2="392" /><line x1="107" y1="404" x2="153" y2="404" /><line x1="107" y1="416" x2="153" y2="416" /><line x1="107" y1="428" x2="153" y2="428" /><line x1="107" y1="440" x2="153" y2="440" /><line x1="107" y1="452" x2="153" y2="452" /><line x1="107" y1="464" x2="153" y2="464" /><line x1="107" y1="476" x2="153" y2="476" /><line x1="107" y1="488" x2="153" y2="488" /><line x1="107" y1="500" x2="153" y2="500" /><line x1="107" y1="512" x2="153" y2="512" /><line x1="107" y1="524" x2="153" y2="524" />
                     </g>
                  </g>
               </g>

               <g id="eyeball-assembly" style="transition: transform 0.15s ease-out;">
                  <circle cx="130" cy="364" r="26" fill="#1c1e22" stroke="#000" stroke-width="2"/>
                  <circle cx="130" cy="364" r="23" fill="#0a0b0c"/>
                  
                  <circle cx="147" cy="388" r="3.5" fill="#1a0000" stroke="#000" stroke-width="1"/>
                  <circle id="indicator-dot" cx="147" cy="388" r="2.5" fill="#ff2200" opacity="0.8" filter="url(#softGlow)"/>

                  <circle id="eye-halo" cx="130" cy="364" r="25" fill="#330800" opacity=".05" filter="url(#eyeBloom)"/>
                  
                  <g id="eye-pupil" style="transition: transform 0.15s ease-out;">
                    <circle id="eye-layer-idle" cx="130" cy="364" r="17.6" fill="url(#eyeGradIdle)" filter="url(#softGlow)" class="eye-layer" opacity="1" />
                    <circle id="eye-layer-listen" cx="130" cy="364" r="17.6" fill="url(#eyeGradListen)" filter="url(#softGlow)" class="eye-layer" opacity="0" />
                    <circle id="eye-layer-process" cx="130" cy="364" r="17.6" fill="url(#eyeGradProcess)" filter="url(#softGlow)" class="eye-layer" opacity="0" />
                    <circle id="eye-layer-respond" cx="130" cy="364" r="17.6" fill="url(#eyeGradRespond)" filter="url(#softGlow)" class="eye-layer" opacity="0" />
                    <circle id="eye-center" cx="130" cy="364" r="6.6" fill="#ffe855" />
                    <circle cx="128" cy="362" r="2.2" fill="#ffffff" opacity="0.7" />
                  </g>

                  <g clip-path="url(#eyeballClip)">
                     <path id="eye-lid" d="M 80 200 L 180 200 L 180 364 L 156 364 A 26 26 0 0 0 104 364 L 80 364 Z" fill="url(#lidGrad)" stroke="#000" stroke-width="2" style="transform:translateY(0px); transition:transform 0.7s ease-in-out;"/>
                     <path id="eye-lid-bottom" d="M 80 500 L 180 500 L 180 364 L 156 364 A 26 26 0 0 1 104 364 L 80 364 Z" fill="url(#lidGradFlip)" stroke="#000" stroke-width="2" style="transform:translateY(0px); transition:transform 0.7s ease-in-out;"/>
                  </g>
               </g>
            </g>

            <path d="M 92 359 L 97 361 L 97 367 L 92 369 Z" fill="#050505"/>
            <path d="M 92 379 L 97 381 L 97 389 L 92 391 Z" fill="#050505"/>
            
            <rect id="danger-ring" x="97" y="269" width="66" height="190" rx="33" fill="none" stroke="#ff2200" stroke-width="2" opacity="0"/>
          </g>
        </g>
      </g>
    </svg>
  </div>

  <div class="controls">
    <button id="btn-idle" class="active" onclick="window.applyState('idle')">IDLE</button>
    <button id="btn-listening" onclick="window.applyState('listening')">LISTEN</button>
    <button id="btn-processing" onclick="window.applyState('processing')">PROCESS</button>
    <button id="btn-responding" onclick="window.applyState('responding')">RESPOND</button>
  </div>

  <script>
    const config = { zoom: 85, eye_speed: 50 };
    const scale = config.zoom / 100;
    
    // Tightly cropped dimensions
    const width = 280 * scale;
    const height = 320 * scale;
    const scene = document.getElementById('scene');
    scene.style.width = width + 'px';
    scene.style.height = height + 'px';

    const el = {
      head: document.getElementById('glados-head'),
      bodyPivot: document.getElementById('body-pivot'),
      eyeLayerIdle: document.getElementById('eye-layer-idle'),
      eyeLayerListen: document.getElementById('eye-layer-listen'),
      eyeLayerProcess: document.getElementById('eye-layer-process'),
      eyeLayerRespond: document.getElementById('eye-layer-respond'),
      eyeHalo: document.getElementById('eye-halo'),
      eyeCenter: document.getElementById('eye-center'),
      pupil: document.getElementById('eye-pupil'),
      eyeball: document.getElementById('eyeball-assembly'),
      bellows: document.getElementById('bellows'),
      lidTop: document.getElementById('eye-lid'),
      lidBot: document.getElementById('eye-lid-bottom'),
      dangerRing: document.getElementById('danger-ring'),
      ledMatrices: document.querySelectorAll('.led-matrix'),
      sideInds: [
        document.getElementById('ind-l1'), document.getElementById('ind-l2'), 
        document.getElementById('ind-r1'), document.getElementById('ind-r2')
      ]
    };

    let stateNow = 'idle', talkPhase = 0, talkAnim = null, lidTimer = null;
    let pupilTimer = null, idleTimer = null, glitchTimer = null;
    let currentBaseLid = 0;

    function setHead(rot, tx, ty, scale = 1.0, dur, ease = "cubic-bezier(0.34,1.06,0.64,1)") {
      el.head.style.transition = `transform ${dur}s ${ease}`;
      el.head.style.transform = `rotate(${rot}deg) translate(${tx}px,${ty}px) scale(${scale})`;
    }

    function setBodySwivel(rot, sx, dur) {
      el.bodyPivot.style.transition = `transform ${dur || 2.0}s cubic-bezier(0.45,0.05,0.55,0.95)`;
      el.bodyPivot.style.animation = 'none';
      el.bodyPivot.style.transform = `rotate(${rot}deg) scaleX(${sx || 1})`;
    }

    function resetBodySwivel() {
      el.bodyPivot.style.transition = '';
      el.bodyPivot.style.animation = '';
      el.bodyPivot.style.transform = '';
    }

    function setLid(amount, dur = 0.7) {
      const px = amount * 17; 
      el.lidTop.style.transition = `transform ${dur}s ease-in-out`;
      el.lidBot.style.transition = `transform ${dur}s ease-in-out`;
      el.lidTop.style.transform = `translateY(${px}px)`;
      el.lidBot.style.transform = `translateY(${-px}px)`;
    }

    function setBaseLid(amount, dur = 0.7) {
      currentBaseLid = amount;
      setLid(amount, dur);
    }

    function setPupil(px, py) {
      el.pupil.style.transform = `translate(${px}px, ${py}px)`;
      let ey = py * 1.5;
      el.eyeball.style.transform = `translateY(${ey}px)`;
      el.bellows.style.transform = `translateY(${ey}px)`;
    }

    function setLEDs(color, opacity) {
      document.querySelectorAll('.led-dot').forEach(d => {
        d.setAttribute('fill', color);
        d.setAttribute('opacity', opacity);
      });
      el.sideInds.forEach(i => {
        i.setAttribute('fill', color);
        i.setAttribute('opacity', opacity);
      });
    }

    function startLidBehavior() {
      if (lidTimer) clearTimeout(lidTimer);
      function loop() {
        if (stateNow === 'idle') {
          let val = Math.max(0, Math.min(1, currentBaseLid + (Math.random() - 0.5) * 0.15));
          setLid(val, 0.5 + Math.random() * 0.8);
          lidTimer = setTimeout(loop, 1500 + Math.random() * 2500);
        } else if (stateNow === 'processing') {
          let val = 0.5 + (Math.random() * 0.35); 
          setLid(val, 0.04 + Math.random() * 0.08);
          lidTimer = setTimeout(loop, 40 + Math.random() * 120);
        }
      }
      loop();
    }

    function stopLidBehavior() {
      if (lidTimer) { clearTimeout(lidTimer); lidTimer = null; }
    }

    const IDLE_BEHAVIORS = [
      { name: 'passive', exec() { setHead(0, 0, 0, 1.0, 2.4); setBaseLid(0, 1.0); resetBodySwivel(); }, min: 6000, max: 13000, weight: 4 },
      { name: 'scan_right', exec() { setHead(12, 0, -5, 0.98, 1.4); setBaseLid(0, 1.0); setBodySwivel(-2, 1, 1.8); }, min: 3500, max: 7000, weight: 1.5 },
      { name: 'scan_left', exec() { setHead(-12, 0, -5, 0.98, 1.4); setBaseLid(0, 1.0); setBodySwivel(2, 1, 1.8); }, min: 3500, max: 7000, weight: 1.5 },
      { name: 'curious', exec() { setHead(8, 0, -20, 1.05, 1.2); setBaseLid(0, 0.8); setBodySwivel(-2, 1, 1.6); }, min: 4000, max: 8000, weight: 2 },
      { name: 'contemptuous', exec() { setHead(-6, 0, 15, 0.95, 1.8); setBaseLid(0.65, 1.0); setBodySwivel(1.5, 1, 2.0); setTimeout(() => { if (stateNow === 'idle') setBaseLid(0, 1.5); }, 1500); }, min: 5000, max: 10000, weight: 2 },
      { name: 'alert', exec() { setHead(0, 0, -25, 1.08, 0.28); setBaseLid(0, 0.2); setBodySwivel(-1, 1, 0.4); }, min: 1500, max: 3000, weight: 1 },
      { name: 'bored', exec() { setHead(2, 0, 20, 0.96, 2.8); setBaseLid(0.7, 1.5); setBodySwivel(1, 1, 3.0); setTimeout(() => { if (stateNow === 'idle') setBaseLid(0, 1.5); }, 1500); }, min: 7000, max: 14000, weight: 1.5 },
      { name: 'full_swivel', exec() { setBodySwivel(-6, 0.96, 2.5); setTimeout(() => { setHead(6, 0, -3, 1.02, 1.2); setBaseLid(0, 0.8); }, 600); }, min: 4000, max: 8000, weight: 0.8 },
      { name: 'glitch', exec() {
          let count = 0;
          if (glitchTimer) clearInterval(glitchTimer);
          glitchTimer = setInterval(() => {
            if (stateNow !== 'idle' || count > 12) {
              clearInterval(glitchTimer);
              if (stateNow === 'idle') {
                el.eyeHalo.setAttribute('fill', '#330800');
                el.eyeCenter.setAttribute('fill', '#ffcc00');
                setHead(0, 0, 0, 1.0, 0.4);
              }
              return;
            }
            setHead((Math.random()-0.5)*10, (Math.random()-0.5)*8, (Math.random()-0.5)*8, 1.0, 0.05, "linear");
            if (count % 2 === 0) {
               el.eyeHalo.setAttribute('fill', '#110000');
               el.eyeCenter.setAttribute('fill', '#884400');
            } else {
               el.eyeHalo.setAttribute('fill', '#ffb800');
               el.eyeCenter.setAttribute('fill', '#ffffff');
            }
            count++;
          }, 60);
      }, min: 4000, max: 7000, weight: 0.3 }
    ];

    function dartPupil() {
      if (stateNow === 'idle') {
        const activity = config.eye_speed !== undefined ? config.eye_speed : 50;
        
        if (activity === 0) {
          setPupil(0, 0);
          pupilTimer = setTimeout(dartPupil, 1000);
          return;
        }

        const max = 7;
        const px = (Math.random() - 0.5) * max * 2;
        const py = (Math.random() - 0.5) * max * 2;
        setPupil(px, py);

        let base = 600;
        let rand = 2500;
        if (activity > 50) {
          let m = 1.0 - ((activity - 50) / 50) * 0.85;
          base *= m; rand *= m;
        } else {
          let m = 1.0 + ((50 - activity) / 50) * 5.0;
          base *= m; rand *= m;
        }

        pupilTimer = setTimeout(dartPupil, base + Math.random() * rand);
      }
    }

    function runNextIdleBehavior() {
      if (stateNow !== 'idle') return;
      const total = IDLE_BEHAVIORS.reduce((s, b) => s + b.weight, 0);
      let r = Math.random() * total, chosen = IDLE_BEHAVIORS[0];
      for (const b of IDLE_BEHAVIORS) { r -= b.weight; if (r <= 0) { chosen = b; break; } }
      chosen.exec();
      idleTimer = setTimeout(runNextIdleBehavior, chosen.min + Math.random() * (chosen.max - chosen.min));
    }

    function startIdleCycle() {
      stopIdleCycle();
      dartPupil();
      idleTimer = setTimeout(runNextIdleBehavior, 2000 + Math.random() * 3000);
    }

    function stopIdleCycle() {
      if (idleTimer) { clearTimeout(idleTimer); idleTimer = null; }
      if (pupilTimer) { clearTimeout(pupilTimer); pupilTimer = null; }
      if (glitchTimer) { clearInterval(glitchTimer); glitchTimer = null; }
    }

    const TALK_MOVES = [
      { r: -10, tx: -8, ty: -18, s: 1.02, dur: 1.8, lid: 0.1, px: 0, py: -2 },
      { r: 4, tx: 0, ty: 16, s: 1.08, dur: 1.2, lid: 0.85, px: 0, py: 4 },
      { r: 2, tx: 0, ty: 10, s: 1.04, dur: 1.0, lid: 0.5, px: 0, py: 2 },
      { r: 12, tx: 10, ty: -12, s: 0.96, dur: 2.2, lid: 0.1, px: 0, py: -1 },
      { r: 0, tx: 0, ty: 25, s: 1.10, dur: 1.8, lid: 0.9, px: 0, py: 5 },
      { r: -6, tx: 6, ty: -22, s: 0.98, dur: 1.0, lid: 0.1, px: 0, py: -3 },
      { r: 4, tx: -3, ty: 6, s: 1.03, dur: 2.0, lid: 0.4, px: 0, py: 1 },
      { r: -3, tx: 0, ty: 22, s: 1.15, dur: 1.2, lid: 0.95, px: 0, py: 6 },
      { r: 6, tx: 3, ty: -6, s: 1.0, dur: 1.5, lid: 0.2, px: 0, py: 0 },
    ];

    function startTalkAnim() {
      if (talkAnim) clearTimeout(talkAnim);
      talkPhase = 0;
      function step() {
        const m = TALK_MOVES[talkPhase % TALK_MOVES.length];
        setHead(m.r, m.tx, m.ty, m.s, m.dur, "ease-in-out");
        setLid(m.lid, m.dur);
        setPupil(m.px, m.py);
        setBodySwivel(m.r * -0.6, 1, m.dur); 
        talkPhase++;
        talkAnim = setTimeout(step, m.dur * 1000);
      }
      step();
    }

    const animateGlaDOS = (state) => {
      stateNow = state;
      if (talkAnim) clearTimeout(talkAnim);
      stopLidBehavior();
      stopIdleCycle();

      el.ledMatrices.forEach(m => m.classList.remove('pulsing'));
      el.dangerRing.setAttribute('opacity', '0');

      if (state === 'idle') {
        el.eyeLayerIdle.style.opacity = '1'; el.eyeLayerListen.style.opacity = '0'; el.eyeLayerProcess.style.opacity = '0'; el.eyeLayerRespond.style.opacity = '0';
        el.eyeHalo.setAttribute('fill', '#330800'); el.eyeCenter.setAttribute('fill', '#ffcc00');
        setHead(0, 0, 0, 1.0, 2.2); setLid(0, 1.2); setPupil(0, 0); currentBaseLid = 0;
        setLEDs('#ffb800', '0.15');
        resetBodySwivel();
        startLidBehavior();
        startIdleCycle();
      } else if (state === 'listening') {
        el.eyeLayerIdle.style.opacity = '0'; el.eyeLayerListen.style.opacity = '1'; el.eyeLayerProcess.style.opacity = '0'; el.eyeLayerRespond.style.opacity = '0';
        el.eyeHalo.setAttribute('fill', '#00ccff'); el.eyeCenter.setAttribute('fill', '#aaffff');
        setHead(4, 0, -8, 1.06, 1.0); setBaseLid(0.1, 0.4); setPupil(0, -3);
        setLEDs('#00ccff', '1');
        setBodySwivel(-2, 1, 1.4);
      } else if (state === 'processing') {
        el.eyeLayerIdle.style.opacity = '0'; el.eyeLayerListen.style.opacity = '0'; el.eyeLayerProcess.style.opacity = '1'; el.eyeLayerRespond.style.opacity = '0';
        el.eyeHalo.setAttribute('fill', '#ff6600'); el.eyeCenter.setAttribute('fill', '#ffddaa');
        setHead(-2, 0, 10, 0.96, 1.4); setBaseLid(0.65, 0.5); 
        setLEDs('#ff6600', '1');
        setBodySwivel(1, 0.98, 1.8);
        el.ledMatrices.forEach(m => m.classList.add('pulsing'));
        startLidBehavior();
        const dart = () => {
          if (stateNow !== 'processing') return;
          setPupil((Math.random() - 0.5) * 12, 4);
          pupilTimer = setTimeout(dart, 200 + Math.random() * 600);
        };
        dart();
      } else if (state === 'responding') {
        el.eyeLayerIdle.style.opacity = '0'; el.eyeLayerListen.style.opacity = '0'; el.eyeLayerProcess.style.opacity = '0'; el.eyeLayerRespond.style.opacity = '1';
        el.eyeHalo.setAttribute('fill', '#ff2200'); el.eyeCenter.setAttribute('fill', '#ffaaaa');
        el.dangerRing.setAttribute('opacity', '1');
        setLEDs('#ff2200', '1');
        setBodySwivel(0, 1, 0.8);
        startTalkAnim();
      }
    };

    window.applyState = (raw) => {
      const state = (raw || 'idle').toLowerCase();
      document.querySelectorAll('.controls button').forEach(b => b.classList.remove('active'));
      document.getElementById('btn-' + state).classList.add('active');
      animateGlaDOS(state);
    };

    window.applyState('idle');
  </script>
</body>
</html>