Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions docs/notification-feedback.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
# Dashboard feedback

## Audit and presentation rules

The dashboard already had bounded global notifications, but also left raw green action
results beside controls. Action outcomes now use the same titled, text-only dark card,
cream body text, small semantic icon, and explicit dismiss control. Successful feedback
expires; errors and pending work remain until dismissed or replaced. Hover/focus pauses
expiry. Owner and Kids locks, fullscreen placement, and queue limits remain unchanged.

- Power transitions and app lifecycle: titled notifications; remove duplicate inline outcomes.
- Settings and connection tests: pending/result replace the same Settings card.
- Agent, Kids, announcements, presence, initiative, presentation, controller, GPIO,
camera tests, local vision tests, agent setup, and MCP access: shared titled feedback helper.
- Camera stream failures: same Camera notification style; stream status remains inline.
- Home-screen installation action/error: shared feedback helper. Installation readiness stays inline.
- Robot's rapid movement successes and routine telemetry: deliberately quiet.
- Vision answers, pairing instructions, permission reasons, and polled diagnostics:
persistent inline content, not disappearing notifications.

Inline messages use rounded neutral callouts with a small semantic border rather than
all-green text. Action text is hidden only after the notification service accepts it;
when unavailable/suppressed/full, the local message remains a fallback. Persistent power
state remains in the existing state display. Notifications never grant robot authority.

## Verification

Node tests cover replacement timers, pending dismissal, confirmed/false-success power
responses, network failures, queueing, text-only rendering and privacy suppression.
Chromium tests exercise real DOM/CSS at phone, landscape and desktop sizes, title
announcements, icons, duplicate suppression, expiry, inline fallback, and fullscreen.
All robot APIs in these tests are explicit intercepted fixtures; they cause no motion.
6 changes: 5 additions & 1 deletion homebody/static/camera.js
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,11 @@
const messageElement = byId("camera-message");
messageElement.textContent = message;
messageElement.className = `message ${kind}`;
if (kind === "error") window.HomebodyNotifications?.show(`Camera: ${message}`, { id: "camera-stream", kind });
if (kind === "error") {
if (window.HomebodyNotifications?.show(message, { id: "camera-stream", kind, title: "Camera" })) {
messageElement.className += " message-surfaced";
}
}
else if (kind === "ok") window.HomebodyNotifications?.dismiss("camera-stream");
}

Expand Down
12 changes: 6 additions & 6 deletions homebody/static/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
<link rel="manifest" href="/manifest.webmanifest" />
<link rel="icon" href="/static/icon-192.png" sizes="192x192" />
<link rel="apple-touch-icon" href="/static/icon-192.png" />
<link rel="stylesheet" href="/static/style.css?v=68">
<link rel="stylesheet" href="/static/style.css?v=69">
</head>
<body>
<section id="owner-pair-panel" class="owner-access" aria-labelledby="owner-pair-title">
Expand Down Expand Up @@ -920,11 +920,11 @@ <h2>App and host lifecycle</h2>
</main>
<span id="notification-status" class="sr-only" role="status" aria-atomic="true"></span>
<span id="notification-alert" class="sr-only" role="alert" aria-atomic="true"></span>
<script src="/static/notifications.js?v=68"></script>
<script src="/static/owner.js?v=68"></script>
<script src="/static/notifications.js?v=69"></script>
<script src="/static/owner.js?v=69"></script>
<script src="/static/gstwebrtc-api.js"></script>
<script src="/static/camera.js?v=68"></script>
<script src="/static/agent-connection.js?v=68"></script>
<script src="/static/main.js?v=68"></script>
<script src="/static/camera.js?v=69"></script>
<script src="/static/agent-connection.js?v=69"></script>
<script src="/static/main.js?v=69"></script>
</body>
</html>
30 changes: 18 additions & 12 deletions homebody/static/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -650,7 +650,7 @@ $("install-button").addEventListener("click", async () => {
$("install-status").textContent = "Installing";
$("install-help").hidden = true;
$("install-message").textContent = "Installation accepted. Homebody is being added to your home screen.";
$("install-message").className = "message ok";
notifyFeedback($("install-message"), "ok");
} else {
updateInstallUi();
$("install-message").textContent = "Installation dismissed. You can try again from Chrome's menu.";
Expand All @@ -664,16 +664,15 @@ async function registerPwa() {
await navigator.serviceWorker.register("/service-worker.js", { scope: "/" });
} catch (error) {
$("install-message").textContent = `App installation support could not start: ${String(error)}`;
$("install-message").className = "message error";
notifyFeedback($("install-message"), "error");
}
}
registerPwa();

function setMessage(text, kind = "") {
const el = $("form-message");
el.textContent = text;
if (kind) notifyFeedback(el, kind);
else el.className = "message";
notifyFeedback(el, kind || "pending");
}

function fillConfig(config) {
Expand Down Expand Up @@ -2544,7 +2543,7 @@ $("camera-wake-control").addEventListener("click", async () => {

// One operation ID updates progress in place instead of overwriting unrelated notices.
function showPowerToast(text, kind = "pending") {
window.HomebodyNotifications.show(text, { id: "power-transition", kind });
return window.HomebodyNotifications?.show(text, { id: "power-transition", kind, title: "Reachy power" });
}

// Keep details at the control; surface action outcomes consistently across tabs.
Expand All @@ -2555,14 +2554,16 @@ function notifyFeedback(message, kind) {
"announcement-message": "Announcement", "robot-message": "Robot",
"presence-message": "Presence", "initiative-message": "Initiative",
"presentation-message": "Presentation", "bluetooth-message": "Controller",
"gpio-message": "Buttons", "camera-message": "Camera",
"gpio-message": "Buttons", "camera-message": "Camera", "install-message": "Home-screen app",
"agent-setup-status": "Agent setup", "local-vision-message": "Local vision", "mcp-message": "Agent access",
};
// Fast motor commands remain quiet on success. Never toast polling/telemetry.
if (!labels[message.id] || (message.id === "robot-message" && kind !== "error")) return;
window.HomebodyNotifications.show(`${labels[message.id]}: ${message.textContent}`, {
id: `feedback-${message.id}`, kind,
const notice = window.HomebodyNotifications?.show(message.textContent, {
id: `feedback-${message.id}`, kind, title: labels[message.id],
});
// Keep text as a fallback if notifications are unavailable, suppressed, or full.
if (notice) message.className += " message-surfaced";
}

async function setPowerMode(mode, durationMinutes = 60, message = $("power-message")) {
Expand Down Expand Up @@ -2598,6 +2599,7 @@ async function setPowerMode(mode, durationMinutes = 60, message = $("power-messa
const body = await response.json();
if (!response.ok) throw new Error(body.detail || `HTTP ${response.status}`);
const runtime = body.runtime || {};
if (runtime.power_mode !== mode) throw new Error("Requested power mode was not confirmed by the robot runtime");
if (mode === "awake" && (runtime.power_mode !== "awake" || runtime.motors_enabled !== true)) {
throw new Error("Awake was not confirmed by the robot runtime");
}
Expand All @@ -2608,13 +2610,15 @@ async function setPowerMode(mode, durationMinutes = 60, message = $("power-messa
? "Reachy folded safely · motor torque disabled"
: mode === "awake"
? "Reachy awake · motor torque enabled"
: `Power mode: ${body.runtime.power_mode}`;
: mode === "sleep"
? "Sleep mode enabled. Reachy is resting."
: "Meeting mode enabled. Reachy will stay quiet.";
message.className = "message ok";
showPowerToast(message.textContent, "ok");
if (showPowerToast(message.textContent, "ok")) message.className += " message-surfaced";
} catch (error) {
message.textContent = String(error);
message.className = "message error";
showPowerToast(message.textContent, "error");
if (showPowerToast(message.textContent, "error")) message.className += " message-surfaced";
} finally {
activeButtons.forEach((button) => {
button.textContent = button.dataset.idleLabel;
Expand Down Expand Up @@ -2647,7 +2651,9 @@ async function responseDetail(response) {
function setPowerMessage(text, kind) {
$("power-message").textContent = text;
$("power-message").className = `message ${kind}`;
window.HomebodyNotifications.show(text, { id: "app-lifecycle", kind });
if (window.HomebodyNotifications?.show(text, { id: "app-lifecycle", kind, title: "Reachy" })) {
$("power-message").className += " message-surfaced";
}
}

$("app-off-button").addEventListener("click", async () => {
Expand Down
24 changes: 17 additions & 7 deletions homebody/static/notifications.js
Original file line number Diff line number Diff line change
Expand Up @@ -53,9 +53,9 @@
item.node.hidden = index >= MAX_VISIBLE;
if (item.node.hidden) pause(item);
else {
if (item.announced !== item.text) {
(item.kind === "error" ? urgent : polite).textContent = item.text;
item.announced = item.text;
if (item.announced !== `${item.title}:${item.text}`) {
(item.kind === "error" ? urgent : polite).textContent = [item.title, item.text].filter(Boolean).join(": ");
item.announced = `${item.title}:${item.text}`;
}
resume(item);
}
Expand Down Expand Up @@ -84,15 +84,16 @@
}
}

function show(text, { id, kind = "info" } = {}) {
function show(text, { id, kind = "info", title = "" } = {}) {
if (suppressed.size) return null;
text = String(text || "").trim();
if (!text) return null;
kind = ["pending", "ok", "error", "info"].includes(kind) ? kind : "info";
ensureRegion();
const signature = `${kind}:${text}`;
title = String(title || "").trim();
const signature = `${kind}:${title}:${text}`;
// Repeated status polls and retries must not spawn or re-time the same notice.
if (id && items.get(id)?.text === text && items.get(id)?.kind === kind) return id;
if (id && items.get(id)?.text === text && items.get(id)?.kind === kind && items.get(id)?.title === title) return id;
if (!id && performance.now() - (recent.get(signature) ?? -Infinity) < 5000) return null;
id ||= `notice-${++serial}`;
let item = items.get(id);
Expand Down Expand Up @@ -125,7 +126,16 @@
pause(item);
item.kind = kind;
item.text = text;
item.content.textContent = text;
item.title = title;
item.content.textContent = "";
if (title) {
const heading = document.createElement("strong");
heading.className = "notification-title";
heading.textContent = title;
const detail = document.createElement("span");
detail.textContent = text;
item.content.append(heading, detail);
} else item.content.textContent = text;
item.node.className = `notification notification-${kind}`;
item.remaining = kind === "error" || kind === "pending" ? null : 6000;
recent.set(signature, performance.now());
Expand Down
14 changes: 7 additions & 7 deletions homebody/static/service-worker.js
Original file line number Diff line number Diff line change
@@ -1,14 +1,14 @@
const CACHE_NAME = "homebody-shell-v68";
const CACHE_NAME = "homebody-shell-v69";
const APP_SHELL = [
"/",
"/manifest.webmanifest",
"/static/style.css?v=68",
"/static/style.css?v=69",
"/static/gstwebrtc-api.js",
"/static/camera.js?v=68",
"/static/main.js?v=68",
"/static/agent-connection.js?v=68",
"/static/owner.js?v=68",
"/static/notifications.js?v=68",
"/static/camera.js?v=69",
"/static/main.js?v=69",
"/static/agent-connection.js?v=69",
"/static/owner.js?v=69",
"/static/notifications.js?v=69",
"/static/icon-192.png",
"/static/icon-512.png",
"/static/icon-maskable-512.png",
Expand Down
11 changes: 9 additions & 2 deletions homebody/static/style.css
Original file line number Diff line number Diff line change
Expand Up @@ -234,8 +234,9 @@ button[aria-pressed="true"] { box-shadow: inset 0 0 0 2px var(--orange); }
.power-actions, .danger-actions, .robot-actions, .left-actions { justify-content: flex-start; flex-wrap: wrap; }
.danger-actions { margin-top: 12px; padding-top: 12px; border-top: 1px solid var(--line); }
.message { min-height: 1.3em; margin: 12px 0 0; color: var(--muted); font-size: .9rem; }
.message.ok { color: var(--ok); }
.message.error { color: var(--danger-text); }
.message:not(:empty) { padding: .7rem .9rem; border: 1px solid var(--line); border-radius: 12px; color: var(--text); line-height: 1.5; }
.message.ok { border-left: 3px solid var(--ok); }
.message.error { border-left: 3px solid var(--danger-text); }

/* Robot tab */
.control-group { padding: 18px; border: 1px solid var(--line-strong); border-radius: 16px; background: var(--paper-2); }
Expand Down Expand Up @@ -806,6 +807,12 @@ body.camera-app-fullscreen-active { overflow: hidden; }
/* Quiet, bounded notifications above phone navigation; never block controls beneath. */
.notifications { position: fixed; bottom: calc(6rem + env(safe-area-inset-bottom)); right: max(1rem, env(safe-area-inset-right)); z-index: 10000; width: min(24rem, calc(100vw - 2rem)); max-height: calc(75dvh - 6rem); overflow-y: auto; display: grid; gap: .5rem; pointer-events: none; }
.notification { display: flex; align-items: center; gap: .75rem; padding: .65rem .75rem .65rem 1rem; background: #222720; color: #fff9eb; border: 1px solid #ffffff30; border-left: 3px solid #b6ada0; border-radius: 12px; box-shadow: 0 6px 24px #0003; pointer-events: auto; animation: notification-enter .16s ease-out; }
.message.message-surfaced { display: none; }
.notification-title { display: block; margin-bottom: .2rem; font-size: .8rem; font-weight: 650; letter-spacing: .02em; color: #e1d7c6; }
.notification-ok::before { content: "✓"; color: #a9ce9d; }
.notification-error::before { content: "!"; color: #ffaaa3; }
.notification-info::before { content: "i"; color: #e1d7c6; }
.notification:not(.notification-pending)::before { flex-shrink: 0; width: 1.5rem; height: 1.5rem; display: grid; place-items: center; border: 1px solid currentColor; border-radius: 50%; font: 600 .9rem system-ui, sans-serif; }
.notification-text { flex: 1; min-width: 0; overflow-wrap: anywhere; font-family: system-ui, sans-serif; font-size: .9rem; line-height: 1.4; }
.notification-queued { margin: 0; padding: .25rem .5rem; font: .8rem system-ui, sans-serif; color: #fff9eb; background: #222720; border-radius: 6px; }
.notification-error { border-left-color: #e76a62; }
Expand Down
2 changes: 1 addition & 1 deletion tests/test_announcements.py
Original file line number Diff line number Diff line change
Expand Up @@ -437,4 +437,4 @@ def test_announcement_ui_exposes_full_tts_controls_and_private_routes() -> None:
assert 'role="alert"' in html
assert "Voice only · do not change power state" in html
assert "homebody-shell-v" in worker
assert "/static/main.js?v=68" in html
assert "/static/main.js?v=69" in html
4 changes: 2 additions & 2 deletions tests/test_home_assistant_bridge.py
Original file line number Diff line number Diff line change
Expand Up @@ -145,8 +145,8 @@ def test_home_assistant_ui_exposes_nested_opt_ins_and_v44_assets() -> None:
assert "toggleHomeAssistantOptions" in script
assert "homebody-shell-v" in worker
for asset in ("style.css", "camera.js", "main.js"):
assert f"/static/{asset}?v=68" in html
assert f'"/static/{asset}?v=68"' in worker
assert f"/static/{asset}?v=69" in html
assert f'"/static/{asset}?v=69"' in worker


def test_runtime_provider_maps_native_daemon_telemetry_without_enabling_controls() -> None:
Expand Down
4 changes: 2 additions & 2 deletions tests/test_kids_mode.py
Original file line number Diff line number Diff line change
Expand Up @@ -616,8 +616,8 @@ def test_kids_static_assets_advance_pwa_cache_together() -> None:
worker = (STATIC / "service-worker.js").read_text(encoding="utf-8")
assert "homebody-shell-v" in worker
for asset in ("style.css", "camera.js", "main.js"):
assert f"/static/{asset}?v=68" in html
assert f'"/static/{asset}?v=68"' in worker
assert f"/static/{asset}?v=69" in html
assert f'"/static/{asset}?v=69"' in worker


def test_runtime_reports_kids_session_start_replacement_and_end_to_bridge() -> None:
Expand Down
6 changes: 3 additions & 3 deletions tests/test_motor_ui.py
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,6 @@ def test_motor_ui_serializes_power_transitions_and_reports_confirmed_state() ->
assert "position: fixed" in style
assert 'data-action-busy="false"' in html
assert 'homebody-shell-v' in worker
assert '/static/style.css?v=68' in html
assert '/static/camera.js?v=68' in html
assert '/static/main.js?v=68' in html
assert '/static/style.css?v=69' in html
assert '/static/camera.js?v=69' in html
assert '/static/main.js?v=69' in html
55 changes: 55 additions & 0 deletions tests/test_notification_presentation_browser.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
"""Presentation and fallback regression checks against the real dashboard DOM."""

import pytest
from test_notifications_browser import browser_page as browser_page


@pytest.mark.parametrize("width", [390, 844, 1440])
def test_feedback_card_replaces_inline_text(browser_page, width):
page, errors = browser_page
page.set_viewport_size({"width": width, "height": 900})
page.locator("#tab-settings").click()
page.evaluate(
"const m=document.getElementById('form-message'); "
"m.textContent='Settings saved'; notifyFeedback(m,'ok')"
)
assert page.locator("#form-message").is_hidden()
assert page.locator(".notification-title").inner_text() == "Settings"
assert page.locator(".notification-text > span").inner_text() == "Settings saved"
assert page.locator("#notification-status").inner_text() == "Settings: Settings saved"
assert page.locator(".notification-ok").evaluate("el => getComputedStyle(el,'::before').content") == '\"✓\"'
page.clock.fast_forward(6000)
assert page.locator(".notification").count() == 0
assert page.locator("#form-message").is_hidden(), "success must not leave stale green text"
page.evaluate(
"HomebodyNotifications.setSuppressed('test',true); "
"notifyFeedback(document.getElementById('form-message'),'error')"
)
assert page.locator("#form-message").is_visible(), "unavailable notification retains inline fallback"
assert not errors


def test_titles_are_text_only_and_updates_announced(browser_page):
page, errors = browser_page
page.evaluate("HomebodyNotifications.show('Saved', {id:'safe',kind:'ok',title:'<img src=x onerror=alert(1)>'})")
assert page.locator(".notification img").count() == 0
page.evaluate("HomebodyNotifications.show('Saved', {id:'safe',kind:'ok',title:'New title'})")
assert page.locator("#notification-status").inner_text() == "New title: Saved"
assert not errors


def test_sleep_feedback_and_false_success(browser_page):
page, errors = browser_page
page.route("**/api/power", lambda route: route.fulfill(json={"runtime":{"power_mode":"sleep"}}))
page.evaluate("setPowerMode('sleep')")
page.wait_for_function("document.querySelector('.notification-ok') !== null")
assert page.locator("#power-message").is_hidden()
assert "Sleep mode enabled" in page.locator(".notification-ok").inner_text()
page.route("**/api/power", lambda route: route.fulfill(json={"runtime":{"power_mode":"standby"}}))
page.evaluate("setPowerMode('sleep')")
page.wait_for_function("document.querySelector('.notification-error') !== null")
assert "not confirmed" in page.locator(".notification-error").inner_text()
page.clock.resume()
page.evaluate("() => new Promise(requestAnimationFrame)")
page.screenshot(path=".pytest_cache/notification-browser/redesigned-phone.png", animations="disabled")
assert not errors
3 changes: 2 additions & 1 deletion tests/test_power_feedback.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,8 @@ def test_power_feedback_and_panel_independent_click():
assert.equal(home.textContent,'Waking…');
assert.equal(home['aria-busy'],'true');
assert.equal(home.disabled,true);
assert.equal(notices()[0].children[0].textContent,'Waking…');
assert.equal(notices()[0].children[0].children[0].textContent,'Reachy power');
assert.equal(notices()[0].children[0].children[1].textContent,'Waking…');
assert.equal(timers.size,0);
notices()[0].children[1].click();
assert.equal(notices().length,0);
Expand Down
Loading