From 0e6d6306a24d8947e71aecd08b05489a0347a7c0 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 02:25:45 +0000 Subject: [PATCH 1/3] Give the robot face a life: one animated buddy for every status surface The connection chip's robot face grows into a character. It is now one NiceGUI element (RobotBuddy, a Vue SFC) instead of three static SVGs driven by a global script, so any number can live on a page, each with its own timers and cleanup. Moods: happy (connected), neutral (simulator), sad (disconnected), plus alarmed (E-STOP: wide eyes, flashing antenna LEDs, shake) and booting (scanning visor). Moving between moods animates: a hop into happy, a slump into sad, relief when an E-STOP clears. While idle it blinks with real eyelids, glances around, hums, gets suspicious, sighs; follows the pointer when it comes near; blushes when hovered; giggles when poked and gets dizzy from a flurry of pokes; and dozes off (Zzz) after three minutes without input, waking on activity. The chip buddy now reacts to what the robot does: - focuses on the arm (lowered lids, eyes on the scene, LEDs chasing) while a program runs or the arm moves - celebrates a program that exits cleanly, winces at one that crashes - is startled by new warnings/errors and by a collision stop - turns alarmed while an E-STOP is latched - waves hello once the page has loaded New appearances: - the E-STOP dialog: a big alarmed buddy (red for the hardware button, amber for the software stop) replaces the Lottie animation, which was fetched from unpkg@latest and lottie.host and so broke offline - the loading overlay: a booting buddy replaces the spinner, and turns sad when no controller answers - the takeover overlay: the moping buddy now bounces off the real card rect and goes "bonk" at each wall - the welcome dialog waves hello, and Quick Start's connection legend shows the three chip moods instead of coloured squares Respects prefers-reduced-motion. Starting a new page build also resets the warnings log's version, so a reloaded page renders the existing history instead of an empty log. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018LkTtn7owaEfndzBhZ3jbE --- tests/test_robot_buddy.py | 124 ++ tests/test_robot_buddy_screen.py | 129 +++ waldo_commander/common/theme.py | 39 - waldo_commander/components/control.py | 22 +- waldo_commander/components/help_menu.py | 33 +- waldo_commander/components/readout.py | 199 ++-- waldo_commander/components/robot_buddy.py | 100 ++ waldo_commander/components/robot_buddy.vue | 1030 +++++++++++++++++ .../components/script_execution.py | 2 + waldo_commander/main.py | 53 +- waldo_commander/static/icons/robot_happy.svg | 26 - .../static/icons/robot_neutral.svg | 22 - waldo_commander/static/icons/robot_sad.svg | 26 - waldo_commander/static/js/robot-faces.js | 404 ------- 14 files changed, 1538 insertions(+), 671 deletions(-) create mode 100644 tests/test_robot_buddy.py create mode 100644 tests/test_robot_buddy_screen.py create mode 100644 waldo_commander/components/robot_buddy.py create mode 100644 waldo_commander/components/robot_buddy.vue delete mode 100644 waldo_commander/static/icons/robot_happy.svg delete mode 100644 waldo_commander/static/icons/robot_neutral.svg delete mode 100644 waldo_commander/static/icons/robot_sad.svg delete mode 100644 waldo_commander/static/js/robot-faces.js diff --git a/tests/test_robot_buddy.py b/tests/test_robot_buddy.py new file mode 100644 index 00000000..d70b4c1d --- /dev/null +++ b/tests/test_robot_buddy.py @@ -0,0 +1,124 @@ +"""The status chip's robot buddy wears the robot's state. + +Driven through the real paths on the fake-serial controller — the E-STOP +and Reset buttons, a jog button, the editor's play button — and read back +off the buddy element the page renders. How the browser draws each mood +and reaction is covered in ``test_robot_buddy_screen.py``. +""" + +import asyncio +import time +from collections.abc import Callable + +import pytest +import waldoctl +from nicegui.testing import User + +from tests.helpers.wait import ( + enable_sim, + ensure_robot_ready_for_motion, + simulate_click, + wait_for_app_ready, +) +from waldo_commander.components.robot_buddy import Mood, Reaction, RobotBuddy +from waldo_commander.components.script_execution import script_exec +from waldo_commander.state import robot_events, ui_state + + +def _buddy(user: User, marker: str) -> RobotBuddy: + element = next(iter(user.find(marker=marker).elements)) + assert isinstance(element, RobotBuddy) + return element + + +async def _wait_for(condition: Callable[[], bool], timeout: float = 10.0) -> bool: + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + if condition(): + return True + await asyncio.sleep(0.02) + return False + + +@pytest.mark.integration +async def test_chip_buddy_follows_estop_and_watches_the_arm_move(user: User) -> None: + """Grey in the simulator, alarmed for as long as an E-STOP is latched + (with its own alarmed buddy in the dialog), and focused on the arm + while it moves.""" + await user.open("/") + await wait_for_app_ready() + await enable_sim(user) + await ensure_robot_ready_for_motion() + + chip = _buddy(user, "readout-robot-buddy") + assert await _wait_for(lambda: chip.mood == Mood.NEUTRAL) + assert await _wait_for(lambda: not chip.busy), "idle arm, idle buddy" + + waldoctl.commander.settings.jog.joint_step_deg = 10.0 + await simulate_click(user, "btn-j1-plus") + assert await _wait_for(lambda: chip.busy, timeout=5.0), ( + "the buddy should focus on the arm while the jog moves it" + ) + assert await _wait_for(lambda: not chip.busy, timeout=10.0), ( + "the buddy should relax once the arm has settled" + ) + + user.find(marker="btn-estop").click() + assert await _wait_for(lambda: chip.mood == Mood.ALARMED) + assert _buddy(user, "estop-buddy").mood == Mood.ALARMED + + user.find(marker="btn-estop-resume").click() + assert await _wait_for(lambda: chip.mood == Mood.NEUTRAL), ( + "Reset clears the E-STOP, so the buddy calms back down" + ) + + +@pytest.mark.integration +async def test_chip_buddy_reacts_to_how_programs_end_and_to_new_warnings( + user: User, +) -> None: + """A clean exit is celebrated, a crash is winced at, the buddy works + along while the program runs, and a new warning startles it.""" + await user.open("/") + await wait_for_app_ready() + + chip = _buddy(user, "readout-robot-buddy") + user.find(marker="tab-program").click() + await asyncio.sleep(0) + program = waldoctl.commander.programs.active + assert program is not None + + async def run(source: str) -> int: + ui_state.active_textarea.value = source + program.source = source + script_exec.last_exit_code = None + user.find(marker="editor-play-btn").click() + assert await _wait_for(lambda: script_exec.last_exit_code is not None, 15.0) + assert script_exec.last_exit_code is not None + return script_exec.last_exit_code + + busy_seen = False + + async def watch_busy() -> None: + nonlocal busy_seen + while not busy_seen: + busy_seen = chip.busy + await asyncio.sleep(0.02) + + watcher = asyncio.create_task(watch_busy()) + assert await run("import time\ntime.sleep(1.0)\n") == 0 + watcher.cancel() + assert busy_seen, "the buddy should be busy while a program runs" + assert chip.last_reaction == Reaction.CELEBRATE + assert await _wait_for(lambda: not chip.busy) + + # Guarded so only the real run crashes: the editor's path preview + # executes the same source under ``__name__ == "__simulation__"``. + crash = "if __name__ == '__main__':\n raise RuntimeError('boom')\n" + assert await run(crash) != 0 + assert chip.last_reaction == Reaction.OOPS + + # The fake-serial backend reports no warnings of its own; add one the + # way the status consumer does when a new condition arrives. + robot_events.add("warning", "Control loop degraded", "p99 over band") + assert await _wait_for(lambda: chip.last_reaction == Reaction.STARTLE) diff --git a/tests/test_robot_buddy_screen.py b/tests/test_robot_buddy_screen.py new file mode 100644 index 00000000..19bb9464 --- /dev/null +++ b/tests/test_robot_buddy_screen.py @@ -0,0 +1,129 @@ +"""Browser-level check that the robot buddy actually animates. + +Everything the buddy does between the server's mood/reaction updates — +greeting, following the pointer, answering pokes, raising the alarm — +happens in its Vue component, so only a real browser can see it. Each +expression is a layer whose opacity the component drives; the checks +read those layers off the live DOM. +""" + +import re +import time + +import pytest +from selenium.webdriver.common.action_chains import ActionChains +from selenium.webdriver.common.by import By +from selenium.webdriver.common.keys import Keys + +from tests.helpers.browser_helpers import dismiss_dialogs, js + +CHIP = ".q-chip .robot-buddy" +DIALOG = ".q-dialog .robot-buddy" + + +def _layer_shown(screen, root: str, layer: str) -> bool: + return bool( + js( + screen, + "const el = document.querySelector(arguments[0] + ' ' + arguments[1]);" + "return !!el && getComputedStyle(el).opacity === '1';", + root, + layer, + ) + ) + + +def _wait(condition, timeout: float, what: str) -> None: + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + if condition(): + return + time.sleep(0.05) + raise AssertionError(f"timed out waiting for {what}") + + +def _pupil_offset(screen) -> tuple[float, float]: + transform = js( + screen, + "return document.querySelector(arguments[0] + ' .bb-pupil').style.transform;", + CHIP, + ) + x, y = (float(v) for v in re.findall(r"-?\d+(?:\.\d+)?", transform)[:2]) + return x, y + + +@pytest.mark.browser +def test_chip_buddy_greets_watches_the_pointer_and_answers_pokes(screen) -> None: + screen.open("/") + + # The page waves hello once it has loaded: a wink from the chip. + _wait( + lambda: _layer_shown(screen, CHIP, ".bb-eyes-wink"), + timeout=30.0, + what="the chip buddy's greeting wink", + ) + dismiss_dialogs(screen) + _wait( + lambda: _layer_shown(screen, CHIP, ".bb-eyes-open") + and not _layer_shown(screen, CHIP, ".bb-eyes-wink"), + timeout=5.0, + what="the greeting to finish", + ) + assert js( + screen, + "return !!document.querySelector(arguments[0] + '.bb-mood-neutral');", + CHIP, + ), "the simulator's buddy is the grey one" + + # Pointer down and to the left of the chip: the pupils follow it. + chip = screen.selenium.find_element(By.CSS_SELECTOR, CHIP) + ActionChains(screen.selenium).move_to_element_with_offset(chip, -200, 150).perform() + _wait( + lambda: _pupil_offset(screen)[0] < -0.3 and _pupil_offset(screen)[1] > 0.2, + timeout=3.0, + what="the pupils to look down-left at the pointer", + ) + + # One poke giggles; a flurry makes it dizzy. + ActionChains(screen.selenium).move_to_element(chip).click().perform() + _wait( + lambda: _layer_shown(screen, CHIP, ".bb-eyes-happy"), + timeout=2.0, + what="a giggle after one poke", + ) + # Three more make four inside the poke window. + ActionChains(screen.selenium).click(chip).click().click().perform() + _wait( + lambda: _layer_shown(screen, CHIP, ".bb-eyes-spiral"), + timeout=2.0, + what="spiral eyes after a flurry of pokes", + ) + + # E-STOP: the chip sounds the alarm and the dialog brings a big one. + ActionChains(screen.selenium).move_by_offset(-400, 300).perform() + ActionChains(screen.selenium).send_keys(Keys.ESCAPE).perform() + _wait( + lambda: js( + screen, + "return !!document.querySelector(arguments[0] + '.bb-mood-alarmed.bb-leds-alarm')" + " && !!document.querySelector(arguments[1] + '.bb-leds-alarm');", + CHIP, + DIALOG, + ), + timeout=5.0, + what="alarmed buddies in the chip and the E-STOP dialog", + ) + js( + screen, + "for (const b of document.querySelectorAll('.q-dialog button'))" + " if (/reset/i.test(b.textContent)) { b.click(); break; }", + ) + _wait( + lambda: js( + screen, + "return !!document.querySelector(arguments[0] + '.bb-mood-neutral');", + CHIP, + ), + timeout=5.0, + what="the chip buddy to calm down after Reset", + ) diff --git a/waldo_commander/common/theme.py b/waldo_commander/common/theme.py index fbb967f8..f694a86f 100644 --- a/waldo_commander/common/theme.py +++ b/waldo_commander/common/theme.py @@ -1392,51 +1392,12 @@ def inject_layout_css() -> None: .file-tree .q-tree__node--selected > .q-tree__node-header .q-tree__node-header-content { color: white !important; font-weight: bold !important; } .file-tree .q-tree__node--parent > .q-tree__node-header .q-tree__node-header-content { font-weight: bold !important; } -/* ========== Robot Face Indicator ========== */ - -/* Robot face SVG transitions */ -.robot-face .pupil { transition: transform 0.45s ease; } -.robot-face .eye-white { transition: opacity 0.25s ease; } - -/* Breathing animations */ -@keyframes breathe-happy { - 0%, 100% { transform: translateY(0); } - 50% { transform: translateY(-2px); } -} -@keyframes breathe-neutral { - 0%, 100% { transform: translateY(0); } - 50% { transform: translateY(-1.5px); } -} -@keyframes breathe-sad { - 0%, 100% { transform: translateY(0); } - 50% { transform: translateY(-2.5px); } -} -.robot-face-happy svg { animation: breathe-happy 6s ease-in-out infinite; } -.robot-face-neutral svg { animation: breathe-neutral 7s ease-in-out infinite; animation-delay: -2s; } -.robot-face-sad svg { animation: breathe-sad 8s ease-in-out infinite; animation-delay: -4s; } - /* Help tab has no panel — hide its indicator to prevent stale marker at startup */ .side-tab-bar.absolute.bottom-0 .q-tab:last-child .q-tab__indicator { display: none !important; } -/* ========== Takeover Overlay ========== */ - -/* Wandering sad robot — DVD-screensaver-style bounce around the viewport. - Dimensions and fixed positioning are load-bearing: robot-faces.js uses - FACE_SIZE = 96 for collision math, and the JS sets `transform` directly - to compose translate + rotate without browser animation interference. */ -.takeover-face { - position: fixed; - top: 0; - left: 0; - width: 96px; - height: 96px; - pointer-events: none; -} - - /* ========== Action Log ========== */ .action-log { diff --git a/waldo_commander/components/control.py b/waldo_commander/components/control.py index a659d5b5..2fc594c9 100644 --- a/waldo_commander/components/control.py +++ b/waldo_commander/components/control.py @@ -28,6 +28,7 @@ global_phase_timer, ) from waldo_commander.components.playback import playback +from waldo_commander.components.robot_buddy import Mood, RobotBuddy from waldo_commander.components.script_execution import script_exec from waldo_commander.components.settings import SettingsContent, _setting_row from waldo_commander.services.control_lease import ( @@ -52,6 +53,9 @@ logger = logging.getLogger(__name__) +_ESTOP_BUDDY_PX = 160 +_DIGITAL_ESTOP_COLOR = "var(--q-warning)" + # Module-level constants and precompiled regexes: avoid recreating them every frame. _AXIS_ORDER = ( "X+", @@ -115,8 +119,13 @@ def __init__(self, client: "RobotClient", ui_client_fn: Callable[[], Any]) -> No self._last_io_state: int = 1 self._digital_active: bool = False + @property + def active(self) -> bool: + """An E-STOP dialog is up: a physical or digital stop is latched.""" + return self._dialog is not None + def show(self, is_physical: bool) -> None: - """Show E-STOP dialog with Lottie animation.""" + """Show the E-STOP dialog with an alarmed robot buddy.""" ui_client = self._ui_client_fn() if not ui_client: return @@ -139,10 +148,13 @@ def show(self, is_physical: bool) -> None: .classes("overlay-card gap-4 items-center") .mark("estop-dialog"), ): - ui.html( - """""", - sanitize=False, - ).classes("w-96") + # Red for the hardware button, amber for the software stop, + # matching the headline below. + RobotBuddy( + Mood.ALARMED, + size=_ESTOP_BUDDY_PX, + color=None if is_physical else _DIGITAL_ESTOP_COLOR, + ).classes("my-4").mark("estop-buddy") if is_physical: ui.label("Physical E-STOP Active").classes( diff --git a/waldo_commander/components/help_menu.py b/waldo_commander/components/help_menu.py index 85704289..fa49e676 100644 --- a/waldo_commander/components/help_menu.py +++ b/waldo_commander/components/help_menu.py @@ -2,6 +2,7 @@ from nicegui import app as ng_app, ui +from waldo_commander.components.robot_buddy import Mood, Reaction, RobotBuddy from waldo_commander.services.keybindings import keybindings_manager @@ -201,13 +202,10 @@ def _build_quickstart_stepper(self, include_safety_step: bool = False) -> None: { "title": "Connecting Your Robot", "description": """ - In the control panel, switch to the **Settings** tab and select your hardware connection. On Linux you'll need access to the serial device — add yourself to the `dialout` group or set up a udev rule. Connection status is shown in the top right corner. - - - ■ Connected to robot hardware - - ■ Robot mode but disconnected - - ■ Simulator mode + In the control panel, switch to the **Settings** tab and select your hardware connection. On Linux you'll need access to the serial device — add yourself to the `dialout` group or set up a udev rule. Connection status is shown by the robot in the top right corner: """, "video": "connecting_to_robot.mp4", + "status_legend": True, }, { "title": "Programming, Recording, and Path Visualization", @@ -281,11 +279,11 @@ def on_accept(e): "w-full rounded-lg" ).props('preload="metadata"').style("max-height: 360px;") - # sanitize=False: content is a hardcoded literal whose inline status-marker - # color spans DOMPurify would otherwise strip. - ui.markdown(step["description"], sanitize=False).classes( + ui.markdown(step["description"]).classes( "text-md text-gray-300" ) + if step.get("status_legend"): + self._build_status_legend() with ui.stepper_navigation(): if i < len(steps) - 1: @@ -301,6 +299,18 @@ def on_accept(e): "Back", on_click=self._stepper.previous ).props("flat") + def _build_status_legend(self) -> None: + """The chip's three moods, drawn by the same buddy that wears them.""" + with ui.column().classes("gap-1 ml-2").mark("status-legend"): + for mood, text in ( + (Mood.HAPPY, "Connected to robot hardware"), + (Mood.SAD, "Robot mode but disconnected"), + (Mood.NEUTRAL, "Simulator mode"), + ): + with ui.row().classes("items-center gap-3 no-wrap"): + RobotBuddy(mood, size=28) + ui.label(text).classes("text-md text-gray-300") + def _on_finish(self) -> None: """Handle finish button click - mark tutorial complete and close dialog.""" ng_app.storage.general[self.FIRST_VISIT_KEY] = True @@ -328,7 +338,12 @@ def create_first_time_dialog(self) -> ui.dialog: with self._dialog: with ui.card().classes("overlay-card tutorial-dialog-card"): with ui.column().classes("w-full h-full gap-0"): - ui.label("Welcome to PAROL Commander!").classes("text-xl font-bold") + with ui.row().classes("items-center gap-3 no-wrap"): + buddy = RobotBuddy(Mood.HAPPY, size=44, interactive=True) + buddy.react(Reaction.GREET) + ui.label("Welcome to PAROL Commander!").classes( + "text-xl font-bold" + ) ui.label( "Let's get you started with a quick tour of the interface." diff --git a/waldo_commander/components/readout.py b/waldo_commander/components/readout.py index cb731846..5f94c12b 100644 --- a/waldo_commander/components/readout.py +++ b/waldo_commander/components/readout.py @@ -3,8 +3,7 @@ import html as html_mod import logging import random -from enum import Enum -from pathlib import Path +import time from nicegui import ui @@ -12,37 +11,55 @@ from waldoctl import ActionStatus from waldo_commander.common.theme import IO_COLOR_OFF, IO_COLOR_ON -from waldo_commander.state import robot_events, ui_state +from waldo_commander.components.robot_buddy import Mood, Reaction, RobotBuddy +from waldo_commander.services.programs import is_any_program_running +from waldo_commander.state import robot_events, robot_state, ui_state logger = logging.getLogger(__name__) -class RobotFace(Enum): - """Robot face states for the connection status indicator.""" - - HAPPY = "happy" - NEUTRAL = "neutral" - SAD = "sad" - - -# Load robot face SVGs at module level for inline rendering (CSS hover needs DOM access) -_ICONS_DIR = Path(__file__).parent.parent / "static" / "icons" -FACE_SVGS = { - RobotFace.HAPPY: (_ICONS_DIR / "robot_happy.svg").read_text(), - RobotFace.NEUTRAL: (_ICONS_DIR / "robot_neutral.svg").read_text(), - RobotFace.SAD: (_ICONS_DIR / "robot_sad.svg").read_text(), -} -_FACE_TOOLTIPS = { - RobotFace.HAPPY: "Connected", - RobotFace.NEUTRAL: "Simulator", - RobotFace.SAD: "Disconnected", +_MOOD_TOOLTIPS = { + Mood.HAPPY: "Connected", + Mood.NEUTRAL: "Simulator", + Mood.SAD: "Disconnected", + Mood.ALARMED: "E-STOP active", } -# Chip background — darker hue of the face icon color +# Chip background — darker hue of the buddy's colour _CHIP_COLORS = { - RobotFace.HAPPY: "var(--color-emerald-400)", - RobotFace.NEUTRAL: "var(--color-gray-400)", - RobotFace.SAD: "var(--color-red-400)", + Mood.HAPPY: "var(--color-emerald-400)", + Mood.NEUTRAL: "var(--color-gray-400)", + Mood.SAD: "var(--color-red-400)", + Mood.ALARMED: "var(--color-red-400)", } +# The buddy watches the arm while it moves; hold that a beat past each stop +# so a train of step jogs reads as one stretch of work, not a flicker. +_MOVING_DEG_S = 0.5 +_MOVING_HOLD_S = 1.0 +_BUDDY_SLEEP_AFTER_S = 180.0 + + +def _chip_style(mood: Mood) -> str: + return ( + f"background-color: {_CHIP_COLORS[mood]} !important;" + " margin: 0;" + " padding: 20px 12px !important;" + " box-shadow: none;" + " border-radius: 10px;" + ) + + +def _status_mood() -> Mood: + """The chip buddy's mood from connection and E-STOP state.""" + status = waldoctl.commander.status + control_panel = ui_state._control_panel + estop = control_panel.estop if control_panel is not None else None + if status.io.estop == 0 or (estop is not None and estop.active): + return Mood.ALARMED + if status.simulator_active: + return Mood.NEUTRAL + if status.connected: + return Mood.HAPPY + return Mood.SAD def _fmt_1f(v: float) -> str: @@ -147,10 +164,9 @@ class ReadoutPanel: def __init__(self) -> None: """Initialize readout panel with UI element references.""" - # Robot face + IO elements - self._robot_face_html: ui.html | None = None - self._robot_face_container: ui.element | None = None - self._robot_face_tooltip: ui.tooltip | None = None + # Robot buddy + IO elements + self._buddy: RobotBuddy | None = None + self._buddy_tooltip: ui.tooltip | None = None self._robot_chip: ui.chip | None = None self._backend_label: ui.label | None = None self._tool_chip: ui.chip | None = None @@ -171,50 +187,16 @@ def __init__(self) -> None: self._action_log_expanded: bool = False # Dirty checking state - self._last_face_state: RobotFace | None = None + self._moving_until: float = 0.0 + self._last_collision: bool = False self._last_tool_key: str | None = None self._last_io_inputs: list[int] | None = None self._last_io_outputs: list[int] | None = None def update_conn_io(self) -> None: - """Update connection face and IO status. Called from status consumer.""" - if self._robot_face_html and self._robot_face_container: - sim_active = waldoctl.commander.status.simulator_active - connected = waldoctl.commander.status.connected - if sim_active: - face = RobotFace.NEUTRAL - elif connected: - face = RobotFace.HAPPY - else: - face = RobotFace.SAD - if face != self._last_face_state: - self._last_face_state = face - self._robot_face_html.set_content(FACE_SVGS[face]) - # Swap CSS class for breathing animation - remove = " ".join( - f"robot-face-{s.value}" for s in RobotFace if s != face - ) - self._robot_face_container.classes( - add=f"robot-face-{face.value}", remove=remove - ) - self._robot_face_container.update() - # Restart JS animations so the new face state animates - ui.run_javascript( - "window.stopRobotFace();" - " window.initRobotFace('" + face.value + "');" - ) - if self._robot_face_tooltip: - self._robot_face_tooltip.text = _FACE_TOOLTIPS[face] - self._robot_face_tooltip.update() - if self._robot_chip: - self._robot_chip.style( - f"background-color: {_CHIP_COLORS[face]} !important;" - " margin: 0;" - " padding: 20px 12px !important;" - " box-shadow: none;" - " border-radius: 10px;" - ) - self._robot_chip.update() + """Update the buddy and IO status. Called from status consumer.""" + if self._buddy is not None: + self._update_buddy() tool_key = waldoctl.commander.status.tool.key if tool_key != self._last_tool_key: @@ -243,6 +225,41 @@ def update_conn_io(self) -> None: color = IO_COLOR_ON if all_vals[i] else IO_COLOR_OFF chip.props(f"color={color}") + def _update_buddy(self) -> None: + assert self._buddy is not None + mood = _status_mood() + if mood != self._buddy.mood: + self._buddy.set_mood(mood) + if self._buddy_tooltip is not None: + self._buddy_tooltip.text = _MOOD_TOOLTIPS[mood] + self._buddy_tooltip.update() + if self._robot_chip is not None: + self._robot_chip.style(_chip_style(mood)) + self._robot_chip.update() + + now = time.monotonic() + speeds = robot_state.speeds + if speeds.size and ( + speeds.max() > _MOVING_DEG_S or speeds.min() < -_MOVING_DEG_S + ): + self._moving_until = now + _MOVING_HOLD_S + self._buddy.set_busy(is_any_program_running() or now < self._moving_until) + + collision = waldoctl.commander.status.collision.active + if collision and not self._last_collision: + self._buddy.react(Reaction.STARTLE) + self._last_collision = collision + + def greet(self) -> None: + """Wave hello once the page has finished loading.""" + if self._buddy is not None: + self._buddy.react(Reaction.GREET) + + def on_script_finished(self, exit_code: int) -> None: + """Cheer a program that ran to completion; wince at one that crashed.""" + if self._buddy is not None: + self._buddy.react(Reaction.CELEBRATE if exit_code == 0 else Reaction.OOPS) + def update_action_log(self) -> None: """Rebuild the action log scroll area from ``commander.status.action``. @@ -300,6 +317,9 @@ def update_event_log(self) -> None: return if robot_events.version == self._event_log_version: return + # Only news startles the buddy, not the history a fresh page renders. + if self._event_log_version >= 0 and self._buddy is not None: + self._buddy.react(Reaction.STARTLE) self._event_log_version = robot_events.version self._event_log_row.set_visibility(bool(robot_events.entries)) self._event_log_html.set_content(_build_event_log_html()) @@ -307,6 +327,7 @@ def update_event_log(self) -> None: def build(self, anchor: str = "tl") -> None: """Render the top-left readout panel as an overlay card.""" + self._event_log_version = -1 with ui.card().classes(f"overlay-panel overlay-card overlay-{anchor}"): with ui.column().classes("gap-1"): with ( @@ -314,39 +335,23 @@ def build(self, anchor: str = "tl") -> None: .classes("items-center w-full no-wrap gap-2") .style("margin: -10px 0 0 -10px; width: calc(100% + 12px);") ): - _init_face = ( - RobotFace.NEUTRAL - if waldoctl.commander.status.simulator_active - else RobotFace.HAPPY - if waldoctl.commander.status.connected - else RobotFace.SAD - ) - self._last_face_state = _init_face - self._robot_chip = ui.chip().style( - f"background-color: {_CHIP_COLORS[_init_face]} !important;" - " margin: 0;" - " padding: 20px 12px !important;" - " box-shadow: none;" - " border-radius: 10px;" - ) + mood = _status_mood() + self._robot_chip = ui.chip().style(_chip_style(mood)) with self._robot_chip: - self._robot_face_container = ( - ui.element("div") - .classes(f"robot-face robot-face-{_init_face.value}") + self._buddy = ( + RobotBuddy( + mood, + interactive=True, + sleep_after_s=_BUDDY_SLEEP_AFTER_S, + ) .style( - "width: 36px; height: 36px;" - " margin-top: 4px;" + "margin-top: 4px;" " filter: drop-shadow(0 1px 1px rgba(0,0,0,0.4));" ) - .mark("readout-robot-face") + .mark("readout-robot-buddy") ) - with self._robot_face_container: - self._robot_face_html = ui.html( - FACE_SVGS[_init_face], sanitize=False - ).style("width: 36px; height: 36px") - self._robot_face_tooltip = ui.tooltip( - _FACE_TOOLTIPS[_init_face] - ) + with self._buddy: + self._buddy_tooltip = ui.tooltip(_MOOD_TOOLTIPS[mood]) self._backend_label = ( ui.label(ui_state.active_robot.name) .classes("text-lg font-medium ml-2") diff --git a/waldo_commander/components/robot_buddy.py b/waldo_commander/components/robot_buddy.py new file mode 100644 index 00000000..957d62ca --- /dev/null +++ b/waldo_commander/components/robot_buddy.py @@ -0,0 +1,100 @@ +"""Robot buddy: the animated little robot that fronts Waldo Commander's state. + +One element per appearance (status chip, e-stop dialog, loading screen, ...). +Each instance animates on its own in the browser — blinking, glancing +around, dozing off, following the pointer — while the server only sets its +``mood``, whether it is ``busy``, and asks for one-shot reactions. +""" + +from enum import StrEnum + +from nicegui import ui + + +class Mood(StrEnum): + """Resting expression and colour.""" + + HAPPY = "happy" + """Green: connected to the robot.""" + NEUTRAL = "neutral" + """Grey: driving the simulator.""" + SAD = "sad" + """Red: robot mode with no robot answering.""" + ALARMED = "alarmed" + """Red, wide-eyed, flashing antennae: an E-STOP is active.""" + BOOTING = "booting" + """Grey with a scanning visor: waiting for the controller.""" + + +class Reaction(StrEnum): + """One-shot animations, after which the buddy settles back into its mood.""" + + GREET = "greet" + CELEBRATE = "celebrate" + OOPS = "oops" + STARTLE = "startle" + + +class RobotBuddy(ui.element, component="robot_buddy.vue"): + def __init__( + self, + mood: Mood = Mood.HAPPY, + *, + size: int = 36, + color: str | None = None, + interactive: bool = False, + sleep_after_s: float = 0.0, + roam_avoid: str | None = None, + ) -> None: + """Animated robot character. + + :param mood: resting expression and colour + :param size: edge length of the square element in pixels + :param color: CSS colour overriding the mood's colour + :param interactive: follow the pointer when it comes near, and giggle + (or get dizzy) when clicked + :param sleep_after_s: doze off after this long without pointer or + keyboard activity; 0 never sleeps + :param roam_avoid: wander the viewport DVD-screensaver style, bouncing + off its edges and off the element this CSS selector matches + """ + super().__init__() + self._props["mood"] = Mood(mood).value + self._props["color"] = color + self._props["busy"] = False + self._props["interactive"] = interactive + self._props["sleepAfter"] = sleep_after_s + self._props["roam"] = roam_avoid is not None + self._props["roamAvoid"] = roam_avoid or "" + self._props["reaction"] = None + self._reaction_seq = 0 + self.style(f"width: {size}px; height: {size}px") + + @property + def mood(self) -> Mood: + return Mood(self._props["mood"]) + + @property + def busy(self) -> bool: + return self._props["busy"] + + @property + def last_reaction(self) -> Reaction | None: + reaction = self._props["reaction"] + return Reaction(reaction["name"]) if reaction else None + + def set_mood(self, mood: Mood) -> None: + if self._props["mood"] != mood.value: + self._props["mood"] = mood.value + self.update() + + def set_busy(self, busy: bool) -> None: + """Busy buddies focus: lids lower, eyes on the arm, antenna LEDs chase.""" + if self._props["busy"] != busy: + self._props["busy"] = busy + self.update() + + def react(self, reaction: Reaction) -> None: + self._reaction_seq += 1 + self._props["reaction"] = {"name": reaction.value, "seq": self._reaction_seq} + self.update() diff --git a/waldo_commander/components/robot_buddy.vue b/waldo_commander/components/robot_buddy.vue new file mode 100644 index 00000000..3e3442fb --- /dev/null +++ b/waldo_commander/components/robot_buddy.vue @@ -0,0 +1,1030 @@ + + + + + diff --git a/waldo_commander/components/script_execution.py b/waldo_commander/components/script_execution.py index 8c02cdc7..cdf21a95 100644 --- a/waldo_commander/components/script_execution.py +++ b/waldo_commander/components/script_execution.py @@ -359,6 +359,8 @@ async def _monitor_script_completion( with ui_client: self._reset_state() logger.info("Script %s finished with code %s", filename, rc) + if ui_state._readout_panel is not None: + ui_state.readout_panel.on_script_finished(rc) except Exception as e: logger.error("Error monitoring script process: %s", e) with ui_client: diff --git a/waldo_commander/main.py b/waldo_commander/main.py index a2d2c1ec..f8907954 100644 --- a/waldo_commander/main.py +++ b/waldo_commander/main.py @@ -53,6 +53,7 @@ from waldo_commander.components.playback import playback from waldo_commander.components.script_execution import script_exec from waldo_commander.components.readout import ReadoutPanel +from waldo_commander.components.robot_buddy import Mood, RobotBuddy from waldo_commander.constants import config, DEFAULT_CAMERA, RESERVED_TAB_IDS from waldo_commander.numba_pipelines import ( pose_extraction_pipeline, @@ -1030,12 +1031,7 @@ async def restore_active_tabs(): def build_page_content() -> None: """Build the Move page UI.""" - # Lottie player for E-STOP dialog animations; load early in HEAD. - ui.add_head_html( - '' - ) ui.add_head_html('') - ui.add_head_html('') with ui.column().classes("relative w-screen h-screen overflow-hidden gap-0"): with ui.column().classes("absolute inset-0 z-0"): @@ -1049,7 +1045,7 @@ async def _init(): # No STATUS frame (com_port configured but no robot wired). # The scene only needs local URDF assets and guarded client # calls, so render it anyway instead of blocking the page. - loading_spinner.set_visibility(False) + loading_buddy.set_mood(Mood.SAD) loading_status.text = ( "Robot disconnected — proceeding without live data" ) @@ -1087,6 +1083,7 @@ async def _init(): scene_loading_overlay.classes("opacity-0 pointer-events-none") await asyncio.sleep(0.4) scene_loading_overlay.delete() + readout_panel.greet() ui.timer(0.05, _init, once=True) @@ -1104,7 +1101,7 @@ async def _init(): f"background: {bg}; transition: opacity 0.4s ease;" ) as scene_loading_overlay ): - loading_spinner = ui.spinner("dots", size="xl", color="grey") + loading_buddy = RobotBuddy(Mood.BOOTING, size=96).mark("loading-buddy") loading_status = ui.label("Connecting to controller...").style( "color: grey; font-size: 0.9rem;" ) @@ -1468,36 +1465,26 @@ def _build_takeover_overlay(message: str) -> None: Used as the entire page body for fresh shadow tabs, and as a top-layer overlay for previously-active tabs that have been evicted by another tab. - All visual styling lives in theme.py under the `Takeover Overlay` section; - this function only assigns class names. Idempotent per-client: sets a flag on the current Client instance so repeat callers (e.g. check_ping firing on a shadow tab that was already built with an overlay by index_page) skip a duplicate build. """ - from waldo_commander.components.readout import FACE_SVGS, RobotFace - c = ui.context.client if getattr(c, "_waldo_overlay_shown", False): return c._waldo_overlay_shown = True # type: ignore[attr-defined] # ty: ignore[unresolved-attribute] - # robot-faces.js is normally loaded by build_page_content, which the - # shadow branch skips. Load it here so initRobotFace / startRobotMope - # are defined when the run_javascript bootstrap fires below. - ui.add_head_html('') - with ui.column().classes( "fixed inset-0 z-[9999] items-center justify-center bg-black/60" ): - # Wandering sad robot — sibling of the card. JS sets transform to - # mope around the viewport, avoiding the centered card's footprint. - with ui.element("div").classes("robot-face robot-face-sad takeover-face"): - ui.html(FACE_SVGS[RobotFace.SAD], sanitize=False).style( - "width: 96px; height: 96px;" - ) + # Moping buddy — a sibling of the card, wandering the viewport and + # bouncing off its edges and the card. + RobotBuddy(Mood.SAD, size=96, roam_avoid=".takeover-card") - with ui.column().classes("overlay-card items-center max-w-sm p-8"): + with ui.column().classes( + "overlay-card takeover-card items-center max-w-sm p-8" + ): ui.label("Waldo Commander").classes("text-xl font-semibold") ui.label(message).classes("text-sm text-center opacity-90") @@ -1509,26 +1496,6 @@ def _take_over() -> None: "color=primary unelevated rounded" ).classes("mt-2") - # Bootstrap face animations + wandering. The robot-faces.js script tag - # uses `defer`, so the functions may not be defined yet when this JS - # arrives over the websocket. Poll briefly for them. - ui.run_javascript( - """ - (function bootstrap(retries) { - if (typeof window.startRobotMope === 'function') { - if (typeof window.initRobotFace === 'function') { - window.initRobotFace('sad'); - } - window.startRobotMope(); - } else if (retries > 0) { - setTimeout(() => bootstrap(retries - 1), 50); - } else { - console.warn('takeover overlay: robot-faces.js never loaded'); - } - })(60); - """ - ) - @ui.page("/") async def index_page(): diff --git a/waldo_commander/static/icons/robot_happy.svg b/waldo_commander/static/icons/robot_happy.svg deleted file mode 100644 index 1578ae0a..00000000 --- a/waldo_commander/static/icons/robot_happy.svg +++ /dev/null @@ -1,26 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/waldo_commander/static/icons/robot_neutral.svg b/waldo_commander/static/icons/robot_neutral.svg deleted file mode 100644 index f6e5f527..00000000 --- a/waldo_commander/static/icons/robot_neutral.svg +++ /dev/null @@ -1,22 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - diff --git a/waldo_commander/static/icons/robot_sad.svg b/waldo_commander/static/icons/robot_sad.svg deleted file mode 100644 index 12449975..00000000 --- a/waldo_commander/static/icons/robot_sad.svg +++ /dev/null @@ -1,26 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/waldo_commander/static/js/robot-faces.js b/waldo_commander/static/js/robot-faces.js deleted file mode 100644 index a21e067e..00000000 --- a/waldo_commander/static/js/robot-faces.js +++ /dev/null @@ -1,404 +0,0 @@ -/* Animated robot face system for the connection status indicator. */ - -function sleep(ms) { return new Promise(r => setTimeout(r, ms)); } -function rand(a, b) { return a + Math.random() * (b - a); } - -class Robot { - constructor(prefix) { - this.prefix = prefix; - this.eyeL = document.getElementById(`${prefix}-eyeL`); - this.eyeR = document.getElementById(`${prefix}-eyeR`); - this.pupilL = document.getElementById(`${prefix}-pupilL`); - this.pupilR = document.getElementById(`${prefix}-pupilR`); - this.blinkEl = document.getElementById(`${prefix}-blink`); - this.squintEl = document.getElementById(`${prefix}-squint`) || null; - this.droopEl = document.getElementById(`${prefix}-droop`) || null; - this.mouthContainer = document.getElementById(`${prefix}-mouth`); - this.antenna = document.getElementById(`${prefix}-antenna`); - this.busy = false; - if (this.mouthContainer) { - for (const c of this.mouthContainer.children) { - c.style.transition = 'opacity 0.35s ease'; - } - } - } - - movePupils(dx, dy, speed = 0.45) { - const t = `translate(${dx}px, ${dy}px)`; - if (this.pupilL) { - this.pupilL.style.transition = `transform ${speed}s ease`; - this.pupilL.style.transform = t; - } - if (this.pupilR) { - this.pupilR.style.transition = `transform ${speed}s ease`; - this.pupilR.style.transform = t; - } - } - - resetPupils(speed = 0.4) { this.movePupils(0, 0, speed); } - - setEyeSize(r, speed = 0.3) { - if (this.eyeL) this.eyeL.setAttribute('r', r); - if (this.eyeR) this.eyeR.setAttribute('r', r); - } - - resetEyeSize(speed = 0.3) { this.setEyeSize(1.8, speed); } - - hideEyes() { - [this.eyeL, this.eyeR, this.pupilL, this.pupilR].forEach(el => { - if (el) el.style.opacity = '0'; - }); - } - - showEyes(speed = 0.25) { - [this.eyeL, this.eyeR, this.pupilL, this.pupilR].forEach(el => { - if (el) { - el.style.transition = `opacity ${speed}s ease`; - el.style.opacity = '1'; - } - }); - } - - async blink(duration = 120) { - this.hideEyes(); - if (this.blinkEl) this.blinkEl.setAttribute('opacity', '1'); - await sleep(duration); - if (this.blinkEl) this.blinkEl.setAttribute('opacity', '0'); - this.showEyes(0.08); - } - - async showOverlay(el, holdMs, fadeIn = 400) { - if (!el) return; - this.hideEyes(); - el.style.transition = `opacity ${fadeIn}ms ease`; - el.setAttribute('opacity', '1'); - await sleep(holdMs); - el.style.transition = 'opacity 0.3s ease'; - el.setAttribute('opacity', '0'); - this.showEyes(0.2); - } - - setMouth(state, fadeMs = 300) { - if (!this.mouthContainer) return; - for (const c of this.mouthContainer.children) { - c.style.transition = `opacity ${fadeMs}ms ease`; - c.setAttribute('opacity', c.dataset.state === state ? '1' : '0'); - } - } - - gentleWiggle(ms = 600) { - const el = this.antenna; - if (!el) return; - el.style.transition = `transform ${ms * 0.3}ms ease`; - el.style.transform = 'rotate(3deg)'; - setTimeout(() => { - el.style.transition = `transform ${ms * 0.35}ms ease`; - el.style.transform = 'rotate(-2deg)'; - }, ms * 0.3); - setTimeout(() => { - el.style.transition = `transform ${ms * 0.35}ms ease`; - el.style.transform = 'rotate(0deg)'; - }, ms * 0.65); - } - - gentleDroop(ms = 2500) { - const el = this.antenna; - if (!el) return; - el.style.transition = `transform ${ms * 0.4}ms ease`; - el.style.transform = 'rotate(-2.5deg) translateY(0.5px)'; - setTimeout(() => { - el.style.transition = `transform ${ms * 0.6}ms ease`; - el.style.transform = 'rotate(0deg) translateY(0)'; - }, ms * 0.5); - } - - async perform(fn) { - if (this.busy) return; - this.busy = true; - await fn(); - await sleep(500); - this.busy = false; - } -} - -/* Active timer IDs so we can cancel on face switch. */ -let _activeTimers = []; - -function schedule(fn, minMs, maxMs) { - const idx = _activeTimers.length; - function tick() { - fn(); - _activeTimers[idx] = setTimeout(tick, rand(minMs, maxMs)); - } - _activeTimers[idx] = setTimeout(tick, rand(minMs, maxMs)); -} - -/** - * Cancel all scheduled animations. - */ -window.stopRobotFace = function() { - for (const id of _activeTimers) { - clearTimeout(id); - } - _activeTimers = []; -}; - -/** - * Initialize animations for a robot face. - * @param {string} prefix - "happy", "neutral", or "sad" - */ -window.initRobotFace = function(prefix) { - const r = new Robot(prefix); - - // Bail out if SVG elements aren't in the DOM yet - if (!r.eyeL) return; - - // Set default mouth state - if (prefix === 'happy') { - r.setMouth('smile'); - scheduleHappy(r); - } else if (prefix === 'neutral') { - r.setMouth('flat'); - scheduleNeutral(r); - } else if (prefix === 'sad') { - r.setMouth('frown'); - scheduleSad(r); - } -}; - -/* ===== HAPPY animations ===== */ -function scheduleHappy(r) { - // Regular blink - schedule(() => r.perform(async () => { - await r.blink(100); - }), 2500, 5000); - - // Double blink - schedule(() => r.perform(async () => { - await r.blink(80); - await sleep(120); - await r.blink(80); - }), 8000, 14000); - - // Squint + grin - schedule(() => r.perform(async () => { - r.setMouth('grin'); - await r.showOverlay(r.squintEl, 1800, 350); - r.setMouth('smile'); - }), 6000, 12000); - - // Excited: big eyes + open-smile - schedule(() => r.perform(async () => { - r.setEyeSize(2.2); - r.setMouth('open-smile'); - await sleep(1200); - r.resetEyeSize(); - r.setMouth('smile'); - }), 10000, 18000); - - // Antenna wiggle - schedule(() => r.gentleWiggle(), 4000, 9000); -} - -/* ===== NEUTRAL animations ===== */ -function scheduleNeutral(r) { - // Regular blink - schedule(() => r.perform(async () => { - await r.blink(130); - }), 3000, 6000); - - // Look left then right - schedule(() => r.perform(async () => { - r.movePupils(-0.5, 0, 0.5); - await sleep(800); - r.movePupils(0.5, 0, 0.6); - await sleep(800); - r.resetPupils(); - }), 5000, 10000); - - // Look up thinking - schedule(() => r.perform(async () => { - r.movePupils(0.3, -0.4, 0.5); - r.setMouth('slant'); - await sleep(1500); - r.resetPupils(); - r.setMouth('flat'); - }), 8000, 15000); - - // Suspicious squint + zigzag mouth - schedule(() => r.perform(async () => { - r.setEyeSize(1.3); - r.setMouth('zigzag'); - await sleep(1600); - r.resetEyeSize(); - r.setMouth('flat'); - }), 10000, 18000); - - // Mouth twitch - schedule(() => r.perform(async () => { - r.setMouth('slant'); - await sleep(600); - r.setMouth('flat'); - }), 6000, 11000); - - // Antenna twitch - schedule(() => r.gentleWiggle(400), 7000, 14000); -} - -/* ===== SAD animations ===== */ -function scheduleSad(r) { - // Slow blink - schedule(() => r.perform(async () => { - await r.blink(200); - }), 4000, 8000); - - // Look down - schedule(() => r.perform(async () => { - r.movePupils(0, 0.5, 0.6); - await sleep(1800); - r.resetPupils(0.5); - }), 5000, 10000); - - // Look away - schedule(() => r.perform(async () => { - r.movePupils(-0.5, 0.2, 0.7); - await sleep(1400); - r.resetPupils(0.5); - }), 7000, 13000); - - // Droopy eyes + deep frown - schedule(() => r.perform(async () => { - r.setMouth('deep-frown'); - await r.showOverlay(r.droopEl, 2200, 500); - r.setMouth('frown'); - }), 8000, 15000); - - // Watery big eyes + tremble mouth - schedule(() => r.perform(async () => { - r.setEyeSize(2.1); - r.setMouth('tremble'); - await sleep(1800); - r.resetEyeSize(); - r.setMouth('frown'); - }), 12000, 20000); - - // Antenna droop - schedule(() => r.gentleDroop(), 6000, 12000); -} - -/* ===== Bouncing sad robot for the takeover overlay ===== - * DVD-screensaver-style: the face moves at a constant velocity and reflects - * off the viewport edges and the centered card. raf-driven so the motion - * stays smooth at any frame rate. The slow spin lives in CSS (.takeover-face - * animation: takeover-spin), independent of position, so it composes with - * the SVG's own breathing animation. - */ -window.startRobotMope = function() { - // The face element may not be in the DOM yet when this runs (NiceGUI - // dispatches run_javascript over the websocket; the DOM mount can lag - // by a tick or two). Poll briefly before giving up. - let tries = 0; - function start() { - const face = document.querySelector('.takeover-face'); - if (!face) { - if (tries++ < 30) { - setTimeout(start, 50); - } else { - console.warn('startRobotMope: .takeover-face not found'); - } - return; - } - runMope(face); - } - start(); -}; - -function runMope(face) { - const FACE_SIZE = 96; - const SPEED = 90; // pixels per second - const SPIN_DEG_PER_S = 30; // 360° every 12s, slow and steady - const CARD_W = 460; // approximate card footprint with breathing room - const CARD_H = 320; - - function randomVelocity() { - // Avoid axes — pick from a quadrant rotated by a random multiple of 90°. - const angle = (0.15 + Math.random() * 0.7) * Math.PI / 2 + - Math.floor(Math.random() * 4) * Math.PI / 2; - return { vx: Math.cos(angle) * SPEED, vy: Math.sin(angle) * SPEED }; - } - - function randomStart() { - const cx = window.innerWidth / 2; - const cy = window.innerHeight / 2; - const cardL = cx - CARD_W / 2, cardR = cx + CARD_W / 2; - const cardT = cy - CARD_H / 2, cardB = cy + CARD_H / 2; - for (let i = 0; i < 32; i++) { - const px = Math.random() * (window.innerWidth - FACE_SIZE); - const py = Math.random() * (window.innerHeight - FACE_SIZE); - const intersects = px < cardR && px + FACE_SIZE > cardL && - py < cardB && py + FACE_SIZE > cardT; - if (!intersects) return { x: px, y: py }; - } - return { x: 40, y: 40 }; - } - - let { x, y } = randomStart(); - let { vx, vy } = randomVelocity(); - let angle = 0; // degrees - let lastTime = null; - - // Initial paint so we don't flash at (0,0). - face.style.transform = `translate(${x}px, ${y}px) rotate(${angle}deg)`; - - function step(now) { - if (lastTime === null) { - lastTime = now; - requestAnimationFrame(step); - return; - } - // Cap dt so a backgrounded tab doesn't teleport the face on resume. - const dt = Math.min(0.05, (now - lastTime) / 1000); - lastTime = now; - - let nx = x + vx * dt; - let ny = y + vy * dt; - angle = (angle + SPIN_DEG_PER_S * dt) % 360; - - // Viewport edges. - const maxX = window.innerWidth - FACE_SIZE; - const maxY = window.innerHeight - FACE_SIZE; - if (nx < 0) { nx = 0; vx = Math.abs(vx); } - if (nx > maxX) { nx = maxX; vx = -Math.abs(vx); } - if (ny < 0) { ny = 0; vy = Math.abs(vy); } - if (ny > maxY) { ny = maxY; vy = -Math.abs(vy); } - - // Card collision: push out along the axis of shallowest penetration. - const cx = window.innerWidth / 2; - const cy = window.innerHeight / 2; - const cardL = cx - CARD_W / 2, cardR = cx + CARD_W / 2; - const cardT = cy - CARD_H / 2, cardB = cy + CARD_H / 2; - const overlapsCard = nx < cardR && nx + FACE_SIZE > cardL && - ny < cardB && ny + FACE_SIZE > cardT; - if (overlapsCard) { - const penL = (nx + FACE_SIZE) - cardL; - const penR = cardR - nx; - const penT = (ny + FACE_SIZE) - cardT; - const penB = cardB - ny; - const minPen = Math.min(penL, penR, penT, penB); - if (minPen === penL) { nx = cardL - FACE_SIZE; vx = -Math.abs(vx); } - else if (minPen === penR) { nx = cardR; vx = Math.abs(vx); } - else if (minPen === penT) { ny = cardT - FACE_SIZE; vy = -Math.abs(vy); } - else { ny = cardB; vy = Math.abs(vy); } - } - - x = nx; - y = ny; - // Single transform string drives both translate and rotate so nothing - // can race or get overridden by the browser's animation engine. - face.style.transform = `translate(${x}px, ${y}px) rotate(${angle}deg)`; - - requestAnimationFrame(step); - } - - requestAnimationFrame(step); -} From c0a8606d2777eecf0a74419f41362be1052cfa0b Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 03:14:34 +0000 Subject: [PATCH 2/3] Drop the buddy's star eyes and blush Both read as anime rather than as this robot. Celebrating now squints happily instead of starring, and nothing blushes: the greeting, the giggle, the happy idle and hovering all keep their other beats. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018LkTtn7owaEfndzBhZ3jbE --- waldo_commander/components/robot_buddy.vue | 51 ++-------------------- 1 file changed, 3 insertions(+), 48 deletions(-) diff --git a/waldo_commander/components/robot_buddy.vue b/waldo_commander/components/robot_buddy.vue index 3e3442fb..14c5cfaf 100644 --- a/waldo_commander/components/robot_buddy.vue +++ b/waldo_commander/components/robot_buddy.vue @@ -3,8 +3,6 @@ class="robot-buddy" :class="rootClasses" :style="rootStyle" - @pointerenter="onEnter" - @pointerleave="onLeave" @click="onPoke" > ■ Connected to robot hardware -- ■ Robot mode but disconnected -- ■ Simulator mode +- Connected to robot hardware +- Robot mode but disconnected +- Simulator mode