Add ADB management and live car telemetry

This commit is contained in:
2026-07-31 22:23:12 +02:00
parent 34b31c4caf
commit 0e4ce931ec
12 changed files with 911 additions and 23 deletions
+186
View File
@@ -0,0 +1,186 @@
import { useCallback, useEffect, useMemo, useRef, useState, type FormEvent } from "react";
import {
AndroidLogo,
ArrowClockwise,
MagnifyingGlass,
Package,
Play,
Plus,
Trash,
UploadSimple,
WarningCircle
} from "@phosphor-icons/react";
import {
deleteAdbShortcut,
getAdbPackages,
getAdbShortcuts,
post,
uploadApk,
type AdbShortcut,
type AdbTarget,
type InstalledAdbPackage
} from "./api";
const suggestions = [
{ name: "Developer options", value: "com.android.settings/.Settings$DevelopmentSettingsDashboardActivity" },
{ name: "System settings", value: "com.saicmotor.hmi.systemsettings/.SettingsActivity" },
{ name: "Engineering mode", value: "com.saicmotor.hmi.engmode/.home.ui.EngineeringModeActivity" }
] as const;
export function AdbPage({ csrfToken }: { csrfToken: string }) {
const fileInput = useRef<HTMLInputElement>(null);
const [packages, setPackages] = useState<InstalledAdbPackage[]>([]);
const [shortcuts, setShortcuts] = useState<AdbShortcut[]>([]);
const [query, setQuery] = useState("");
const [name, setName] = useState("");
const [target, setTarget] = useState("");
const [targetType, setTargetType] = useState<AdbTarget["type"]>("component");
const [loading, setLoading] = useState(true);
const [busy, setBusy] = useState("");
const [message, setMessage] = useState("");
const [error, setError] = useState("");
const refresh = useCallback(async () => {
setLoading(true);
const [packageResult, shortcutResult] = await Promise.allSettled([getAdbPackages(), getAdbShortcuts()]);
if (packageResult.status === "fulfilled") setPackages(packageResult.value);
if (shortcutResult.status === "fulfilled") setShortcuts(shortcutResult.value);
const failure = packageResult.status === "rejected" ? packageResult.reason : shortcutResult.status === "rejected" ? shortcutResult.reason : null;
setError(failure instanceof Error ? failure.message : failure ? "ADB data could not be loaded" : "");
setLoading(false);
}, []);
useEffect(() => { void refresh(); }, [refresh]);
const visiblePackages = useMemo(() => {
const normalized = query.trim().toLowerCase();
return normalized ? packages.filter((item) => item.packageName.toLowerCase().includes(normalized)) : packages;
}, [packages, query]);
async function install(file: File | undefined) {
if (!file) return;
if (!file.name.toLowerCase().endsWith(".apk")) {
setError("Choose a file ending in .apk");
return;
}
setBusy("install");
setMessage("");
setError("");
try {
await uploadApk(file, csrfToken);
setMessage(`${file.name} was installed`);
await refresh();
} catch (caught) {
setError(caught instanceof Error ? caught.message : "The APK could not be installed");
} finally {
setBusy("");
if (fileInput.current) fileInput.current.value = "";
}
}
async function launch(nextTarget: AdbTarget, busyKey: string) {
setBusy(busyKey);
setMessage("");
setError("");
try {
await post("/api/adb/launch", nextTarget, csrfToken);
setMessage("Opened on the infotainment screen");
} catch (caught) {
setError(caught instanceof Error ? caught.message : "The target could not be opened");
} finally {
setBusy("");
}
}
async function saveShortcut(event: FormEvent) {
event.preventDefault();
setBusy("save");
setMessage("");
setError("");
try {
const shortcut = await post<AdbShortcut>("/api/adb/shortcuts", { name, type: targetType, value: target }, csrfToken);
setShortcuts((current) => [...current, shortcut]);
setName("");
setTarget("");
setMessage("Shortcut saved");
} catch (caught) {
setError(caught instanceof Error ? caught.message : "The shortcut could not be saved");
} finally {
setBusy("");
}
}
async function removeShortcut(id: number) {
setBusy(`delete-${id}`);
try {
await deleteAdbShortcut(id, csrfToken);
setShortcuts((current) => current.filter((item) => item.id !== id));
setMessage("Shortcut removed");
setError("");
} catch (caught) {
setError(caught instanceof Error ? caught.message : "The shortcut could not be removed");
} finally {
setBusy("");
}
}
return (
<div className="adb-page">
{(error || message) && <div className={error ? "status-error" : "adb-success"} role="status">
{error ? <WarningCircle size={21} /> : <AndroidLogo size={21} />}{error || message}
</div>}
<section className="adb-command-grid">
<div className="adb-install-panel">
<div className="panel-heading"><div><h2>Install an APK</h2><p>Upload and replace an app directly on the connected head unit.</p></div><UploadSimple size={30} /></div>
<input ref={fileInput} className="visually-hidden" type="file" accept=".apk,application/vnd.android.package-archive" onChange={(event) => void install(event.target.files?.[0])} />
<button className="primary-button" onClick={() => fileInput.current?.click()} disabled={Boolean(busy)}>
<UploadSimple size={21} />{busy === "install" ? "Installing..." : "Choose APK"}
</button>
<small>Maximum file size: 256 MB. Existing apps are updated in place.</small>
</div>
<form className="shortcut-form" onSubmit={(event) => void saveShortcut(event)}>
<div className="panel-heading"><div><h2>Save a shortcut</h2><p>Use a package name or an explicit Android activity.</p></div><Plus size={28} /></div>
<div className="shortcut-fields">
<label><span>Name</span><input value={name} onChange={(event) => setName(event.target.value)} placeholder="Developer options" required maxLength={48} /></label>
<label><span>Target type</span><select value={targetType} onChange={(event) => setTargetType(event.target.value as AdbTarget["type"])}><option value="component">Activity</option><option value="package">App package</option></select></label>
<label className="target-field"><span>{targetType === "component" ? "Package/activity" : "Package name"}</span><input value={target} onChange={(event) => setTarget(event.target.value)} placeholder={targetType === "component" ? "com.example/.MainActivity" : "com.example.app"} required maxLength={240} /></label>
</div>
<div className="shortcut-form-footer">
<div className="suggestion-list" aria-label="Suggested targets">
{suggestions.map((item) => <button type="button" key={item.name} onClick={() => { setName(item.name); setTargetType("component"); setTarget(item.value); }}>{item.name}</button>)}
</div>
<button className="secondary-button" type="submit" disabled={Boolean(busy)}><Plus size={20} />{busy === "save" ? "Saving..." : "Save"}</button>
</div>
</form>
</section>
{shortcuts.length > 0 && <section className="shortcut-strip" aria-label="Saved shortcuts">
{shortcuts.map((shortcut) => <article key={shortcut.id}>
<button className="shortcut-launch" onClick={() => void launch(shortcut, `shortcut-${shortcut.id}`)} disabled={Boolean(busy)}>
<Play size={20} weight="fill" /><span><strong>{shortcut.name}</strong><small>{shortcut.value}</small></span>
</button>
<button className="icon-button" aria-label={`Remove ${shortcut.name}`} onClick={() => void removeShortcut(shortcut.id)} disabled={Boolean(busy)}><Trash size={19} /></button>
</article>)}
</section>}
<section className="packages-panel">
<div className="packages-toolbar">
<div><h2>Installed apps</h2><p>{loading ? "Reading package manager..." : `${packages.length} packages found`}</p></div>
<label className="package-search"><MagnifyingGlass size={20} /><span className="visually-hidden">Filter installed apps</span><input value={query} onChange={(event) => setQuery(event.target.value)} placeholder="Filter packages" /></label>
<button className="icon-button" aria-label="Refresh installed apps" onClick={() => void refresh()} disabled={loading}><ArrowClockwise size={21} className={loading ? "spin" : ""} /></button>
</div>
<div className="package-list">
{!loading && visiblePackages.length === 0 && <div className="adb-empty">No installed packages match this filter.</div>}
{visiblePackages.map((item) => <article key={item.packageName}>
<Package size={22} />
<div><strong>{item.packageName}</strong><small>{item.apkPath}</small></div>
<span className="package-kind">{item.system ? "System" : "User"}</span>
<button className="icon-button" aria-label={`Launch ${item.packageName}`} title="Launch app" onClick={() => void launch({ type: "package", value: item.packageName }, `package-${item.packageName}`)} disabled={Boolean(busy)}><Play size={18} weight="fill" /></button>
</article>)}
</div>
</section>
</div>
);
}
+10 -5
View File
@@ -1,6 +1,8 @@
import { lazy, Suspense, useCallback, useEffect, useState, type FormEvent } from "react";
import {
ArrowClockwise,
AndroidLogo,
CarProfile,
CheckCircle,
Cpu,
Gauge,
@@ -15,6 +17,8 @@ import {
WarningCircle,
WifiHigh
} from "@phosphor-icons/react";
import { AdbPage } from "./AdbPage";
import { CarPage } from "./CarPage";
import {
ApiError,
getAuthState,
@@ -160,7 +164,7 @@ function AccountScreen({
}
function Dashboard({ auth, onLoggedOut }: { auth: AuthState; onLoggedOut: () => Promise<void> }) {
const [page, setPage] = useState<"home" | "network" | "settings">("home");
const [page, setPage] = useState<"home" | "car" | "adb" | "network" | "settings">("home");
const [status, setStatus] = useState<SystemStatus | null>(null);
const [error, setError] = useState("");
const [connection, setConnection] = useState<"connecting" | "live" | "offline">("connecting");
@@ -202,8 +206,9 @@ function Dashboard({ auth, onLoggedOut }: { auth: AuthState; onLoggedOut: () =>
<div className="brand brand-compact"><Gauge size={30} weight="duotone" /><span>Pi Companion</span></div>
<nav aria-label="Primary navigation">
<button className={`nav-item ${page === "home" ? "active" : ""}`} onClick={() => setPage("home")}><House size={24} weight={page === "home" ? "fill" : "regular"} /><span>Home</span></button>
<button className={`nav-item ${page === "car" ? "active" : ""}`} onClick={() => setPage("car")}><CarProfile size={24} weight={page === "car" ? "fill" : "regular"} /><span>Car</span></button>
<button className={`nav-item ${page === "adb" ? "active" : ""}`} onClick={() => setPage("adb")}><AndroidLogo size={24} weight={page === "adb" ? "fill" : "regular"} /><span>ADB</span></button>
<button className={`nav-item ${page === "network" ? "active" : ""}`} onClick={() => setPage("network")}><WifiHigh size={24} weight={page === "network" ? "fill" : "regular"} /><span>Network</span></button>
<button className="nav-item" disabled><TerminalWindow size={24} /><span>Jobs</span><small>Soon</small></button>
<button className={`nav-item ${page === "settings" ? "active" : ""}`} onClick={() => setPage("settings")}><Gear size={24} weight={page === "settings" ? "fill" : "regular"} /><span>Settings</span></button>
</nav>
<button className="nav-item logout" onClick={() => void logout()}><SignOut size={24} /><span>Sign out</span></button>
@@ -211,8 +216,8 @@ function Dashboard({ auth, onLoggedOut }: { auth: AuthState; onLoggedOut: () =>
<main className="dashboard">
<header className="dashboard-header">
<div>
<p className="eyebrow">{page === "home" ? "System overview" : page === "network" ? "Live diagnostics" : "Companion settings"}</p>
<h1>{page === "home" ? status?.hostname ?? "Your Raspberry Pi" : page === "network" ? "Network activity" : "Settings"}</h1>
<p className="eyebrow">{page === "home" ? "System overview" : page === "car" ? "Live vehicle signals" : page === "adb" ? "Android device bridge" : page === "network" ? "Live diagnostics" : "Companion settings"}</p>
<h1>{page === "home" ? status?.hostname ?? "Your Raspberry Pi" : page === "car" ? "Car" : page === "adb" ? "ADB" : page === "network" ? "Network activity" : "Settings"}</h1>
</div>
<div className="header-actions">
<div className={`connection-state ${connection}`}>
@@ -227,7 +232,7 @@ function Dashboard({ auth, onLoggedOut }: { auth: AuthState; onLoggedOut: () =>
{page === "home" ? <>
{error && <div className="status-error" role="alert"><WarningCircle size={22} />{error}<button onClick={() => void refresh()}>Try again</button></div>}
{!status ? <DashboardSkeleton /> : <StatusContent status={status} />}
</> : page === "network" ? <NetworkActivityPage /> : <SettingsPage csrfToken={auth.csrfToken} />}
</> : page === "car" ? <CarPage /> : page === "adb" ? <AdbPage csrfToken={auth.csrfToken} /> : page === "network" ? <NetworkActivityPage /> : <SettingsPage csrfToken={auth.csrfToken} />}
</main>
</div>
);
+66
View File
@@ -0,0 +1,66 @@
import { useCallback, useEffect, useState } from "react";
import { BatteryMedium, Gauge, Lightning, RoadHorizon, SteeringWheel, WarningCircle } from "@phosphor-icons/react";
import { getCarTelemetry, type Availability, type CarTelemetry } from "./api";
export function CarPage() {
const [telemetry, setTelemetry] = useState<CarTelemetry | null>(null);
const [error, setError] = useState("");
const refresh = useCallback(async () => {
try {
setTelemetry(await getCarTelemetry());
setError("");
} catch (caught) {
setError(caught instanceof Error ? caught.message : "Live car telemetry could not be loaded");
}
}, []);
useEffect(() => {
void refresh();
const interval = window.setInterval(() => void refresh(), 1_000);
return () => window.clearInterval(interval);
}, [refresh]);
const speed = telemetry?.speedKph.available ? telemetry.speedKph.value : null;
const angle = telemetry?.wheelAngleDegrees.available ? telemetry.wheelAngleDegrees.value : 0;
const speedArc = Math.min(Math.max((speed ?? 0) / 180, 0), 1);
return (
<div className="car-page">
{error && <div className="status-error" role="alert"><WarningCircle size={22} />{error}</div>}
<section className="drive-stage">
<div className="speed-cluster">
<div className="speed-arc" style={{ "--speed": speedArc } as React.CSSProperties} />
<Gauge size={28} />
<strong>{speed === null ? "--" : Math.round(speed)}</strong>
<span>km/h</span>
</div>
<div className="wheel-visual">
<div className="wheel-angle"><SteeringWheel size={150} weight="thin" style={{ transform: `rotate(${angle}deg)` }} /></div>
<strong>{telemetry?.wheelAngleDegrees.available ? `${telemetry.wheelAngleDegrees.value.toFixed(1)}°` : "Unavailable"}</strong>
<span>Steering angle</span>
</div>
<div className="gear-cluster">
<span>Gear signal</span>
<strong>{telemetry?.gear.available ? telemetry.gear.value : "--"}</strong>
<small>Raw AVM value</small>
</div>
</section>
<section className="energy-readout">
<TelemetryValue icon={<BatteryMedium size={28} />} label="Traction battery" value={telemetry?.batteryPercent} suffix="%" />
<TelemetryValue icon={<RoadHorizon size={28} />} label="Estimated range" value={telemetry?.rangeKm} suffix="km" />
<article className="telemetry-source"><Lightning size={28} /><div><strong>SAIC bridge needed</strong><p>Battery and range are not exposed in the captured Android property set. The verified AIDL service is the next data source.</p></div></article>
</section>
<footer className="telemetry-footer">
<span>Source: live ADB system properties</span>
<span>{telemetry ? `Updated ${new Date(telemetry.collectedAt).toLocaleTimeString()}` : "Waiting for telemetry"}</span>
</footer>
</div>
);
}
function TelemetryValue({ icon, label, value, suffix }: { icon: React.ReactNode; label: string; value: Availability<number> | undefined; suffix: string }) {
return <article className="energy-value">{icon}<span>{label}</span><strong>{value?.available ? `${Math.round(value.value)} ${suffix}` : "Unavailable"}</strong><small>{value && !value.available ? value.reason : "Live vehicle value"}</small></article>;
}
+66
View File
@@ -100,6 +100,30 @@ export type NetworkActivity = {
messages: string[];
};
export type InstalledAdbPackage = {
packageName: string;
apkPath: string;
system: boolean;
};
export type AdbTarget = { type: "component" | "package"; value: string };
export type AdbShortcut = AdbTarget & {
id: number;
name: string;
createdAt: string;
};
export type CarTelemetry = {
available: true;
collectedAt: string;
speedKph: Availability<number>;
gear: Availability<string>;
wheelAngleDegrees: Availability<number>;
batteryPercent: Availability<number>;
rangeKm: Availability<number>;
};
type ErrorResponse = { error?: { code?: string; message?: string } };
export class ApiError extends Error {
@@ -150,3 +174,45 @@ export async function getNetworkStatus(): Promise<NetworkStatus> {
export async function getNetworkActivity(): Promise<NetworkActivity> {
return parseResponse<NetworkActivity>(await fetch("/api/network/activity", { credentials: "same-origin" }));
}
export async function getAdbPackages(): Promise<InstalledAdbPackage[]> {
const response = await parseResponse<{ packages: InstalledAdbPackage[] }>(
await fetch("/api/adb/packages", { credentials: "same-origin" })
);
return response.packages;
}
export async function getAdbShortcuts(): Promise<AdbShortcut[]> {
const response = await parseResponse<{ shortcuts: AdbShortcut[] }>(
await fetch("/api/adb/shortcuts", { credentials: "same-origin" })
);
return response.shortcuts;
}
export async function uploadApk(file: File, csrfToken: string): Promise<void> {
await parseResponse(
await fetch("/api/adb/install", {
method: "POST",
credentials: "same-origin",
headers: {
"Content-Type": "application/vnd.android.package-archive",
"X-CSRF-Token": csrfToken
},
body: file
})
);
}
export async function deleteAdbShortcut(id: number, csrfToken: string): Promise<void> {
await parseResponse(
await fetch(`/api/adb/shortcuts/${id}`, {
method: "DELETE",
credentials: "same-origin",
headers: { "X-CSRF-Token": csrfToken }
})
);
}
export async function getCarTelemetry(): Promise<CarTelemetry> {
return parseResponse<CarTelemetry>(await fetch("/api/car/telemetry", { credentials: "same-origin" }));
}
+107
View File
@@ -271,3 +271,110 @@ button:disabled { opacity: 0.55; cursor: not-allowed; }
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after { scroll-behavior: auto !important; animation-duration: 0.01ms !important; animation-iteration-count: 1 !important; transition-duration: 0.01ms !important; }
}
.visually-hidden { position: absolute !important; width: 1px !important; height: 1px !important; padding: 0 !important; overflow: hidden !important; clip: rect(0, 0, 0, 0) !important; white-space: nowrap !important; border: 0 !important; }
.icon-button { width: 44px; height: 44px; display: inline-grid; flex: none; place-items: center; padding: 0; color: var(--muted); background: transparent; border: 1px solid var(--line); border-radius: 9px; cursor: pointer; transition: color 140ms ease, background 140ms ease, transform 120ms ease; }
.icon-button:hover { color: var(--text); background: var(--surface-raised); }
.icon-button:active { transform: scale(0.96); }
.adb-page { min-width: 0; display: grid; gap: 18px; }
.adb-success { display: flex; align-items: center; gap: 10px; padding: 13px 16px; color: var(--accent); background: rgba(185, 231, 105, 0.07); border: 1px solid rgba(185, 231, 105, 0.25); border-radius: 10px; }
.adb-command-grid { display: grid; grid-template-columns: minmax(300px, 0.7fr) minmax(520px, 1.3fr); gap: 18px; }
.adb-install-panel, .shortcut-form, .packages-panel { min-width: 0; padding: 25px; background: var(--surface); border: 1px solid var(--line); border-radius: var(--radius); }
.panel-heading { display: flex; align-items: flex-start; justify-content: space-between; gap: 18px; }
.panel-heading > svg { flex: none; color: var(--accent); }
.panel-heading h2, .packages-toolbar h2 { margin: 0 0 6px; font-size: 1.2rem; }
.panel-heading p, .packages-toolbar p { margin: 0; color: var(--muted); font-size: 0.82rem; line-height: 1.45; }
.adb-install-panel { display: flex; flex-direction: column; }
.adb-install-panel .primary-button { align-self: flex-start; margin-top: 34px; }
.adb-install-panel > small { margin-top: 13px; color: var(--muted); line-height: 1.4; }
.shortcut-fields { display: grid; grid-template-columns: 1fr 150px; gap: 14px; margin-top: 23px; }
.shortcut-fields label { display: grid; gap: 7px; color: var(--muted); font-size: 0.76rem; }
.shortcut-fields .target-field { grid-column: 1 / -1; }
.shortcut-fields input, .shortcut-fields select, .package-search input { width: 100%; min-height: 48px; padding: 0 13px; color: var(--text); background: #10140f; border: 1px solid #465043; border-radius: 9px; }
.shortcut-fields select { appearance: auto; }
.shortcut-fields input::placeholder, .package-search input::placeholder { color: #899183; }
.shortcut-fields input:focus-visible, .shortcut-fields select:focus-visible, .package-search input:focus-visible { outline: 3px solid var(--accent); outline-offset: 2px; }
.shortcut-form-footer { display: flex; align-items: flex-end; justify-content: space-between; gap: 16px; margin-top: 18px; }
.suggestion-list { display: flex; flex-wrap: wrap; gap: 7px; }
.suggestion-list button { min-height: 34px; padding: 0 11px; color: var(--muted); background: #121610; border: 1px solid var(--line); border-radius: 7px; font-size: 0.72rem; cursor: pointer; }
.suggestion-list button:hover { color: var(--text); border-color: #53604e; }
.shortcut-strip { display: grid; grid-template-columns: repeat(auto-fit, minmax(245px, 1fr)); gap: 10px; }
.shortcut-strip article { min-width: 0; display: flex; align-items: center; gap: 8px; padding: 8px; background: var(--surface); border: 1px solid var(--line); border-radius: 11px; }
.shortcut-launch { min-width: 0; flex: 1; display: flex; align-items: center; gap: 12px; padding: 8px 9px; color: var(--text); background: transparent; border: 0; text-align: left; cursor: pointer; }
.shortcut-launch > svg { flex: none; color: var(--accent); }
.shortcut-launch span { min-width: 0; display: grid; gap: 4px; }
.shortcut-launch strong, .shortcut-launch small { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.shortcut-launch strong { font-size: 0.88rem; }
.shortcut-launch small { color: var(--muted); font: 0.68rem/1.2 ui-monospace, "Cascadia Code", monospace; }
.packages-panel { padding: 0; overflow: hidden; }
.packages-toolbar { position: relative; display: flex; align-items: center; gap: 13px; padding: 20px 22px; border-bottom: 1px solid var(--line); }
.packages-toolbar > div:first-child { margin-right: auto; }
.package-search { width: min(330px, 35vw); display: flex; align-items: center; gap: 9px; }
.package-search > svg { color: var(--muted); }
.package-list { max-height: min(470px, 50dvh); overflow: auto; }
.package-list article { min-width: 0; display: grid; grid-template-columns: auto minmax(0, 1fr) auto auto; align-items: center; gap: 13px; min-height: 64px; padding: 9px 18px; border-bottom: 1px solid var(--line); }
.package-list article:last-child { border-bottom: 0; }
.package-list article > svg { color: var(--accent); }
.package-list article > div { min-width: 0; display: grid; gap: 5px; }
.package-list strong, .package-list small { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.package-list strong { font-size: 0.84rem; }
.package-list small { color: var(--muted); font: 0.68rem/1.2 ui-monospace, "Cascadia Code", monospace; }
.package-kind { color: var(--muted); font-size: 0.7rem; }
.adb-empty { min-height: 130px; display: grid; place-items: center; padding: 24px; color: var(--muted); text-align: center; }
.car-page { display: grid; gap: 18px; }
.drive-stage { min-height: 430px; display: grid; grid-template-columns: minmax(230px, 0.85fr) minmax(330px, 1.3fr) minmax(180px, 0.65fr); align-items: center; gap: 18px; padding: clamp(28px, 4vw, 58px); overflow: hidden; background: radial-gradient(circle at 50% 48%, rgba(185, 231, 105, 0.08), transparent 31%), var(--surface); border: 1px solid var(--line); border-radius: var(--radius); }
.speed-cluster, .wheel-visual, .gear-cluster { position: relative; display: grid; justify-items: center; }
.speed-cluster { align-content: center; min-height: 280px; }
.speed-cluster > svg { color: var(--accent); }
.speed-cluster strong { margin-top: 15px; font: 750 clamp(4.4rem, 8vw, 7.2rem)/0.82 ui-monospace, "Cascadia Code", monospace; letter-spacing: -0.09em; }
.speed-cluster span, .wheel-visual span, .gear-cluster span { margin-top: 12px; color: var(--muted); font-size: 0.78rem; }
.speed-arc { position: absolute; inset: 0; width: 280px; max-width: 100%; aspect-ratio: 1; margin: auto; border-radius: 50%; background: conic-gradient(from 220deg, var(--accent) calc(var(--speed) * 280deg), var(--line) 0 280deg, transparent 280deg); mask: radial-gradient(circle, transparent 65%, #000 66%); opacity: 0.85; }
.wheel-visual { align-content: center; min-height: 310px; padding: 25px; border-left: 1px solid var(--line); border-right: 1px solid var(--line); }
.wheel-angle { width: 220px; height: 220px; display: grid; place-items: center; color: var(--accent); border-radius: 50%; background: #121610; border: 1px solid #3b4537; }
.wheel-angle svg { transition: transform 250ms cubic-bezier(0.22, 1, 0.36, 1); }
.wheel-visual > strong { margin-top: 22px; font: 750 1.8rem/1 ui-monospace, "Cascadia Code", monospace; }
.gear-cluster strong { margin-top: 8px; font: 750 clamp(5rem, 9vw, 8rem)/0.9 ui-monospace, "Cascadia Code", monospace; color: var(--accent); }
.gear-cluster small { margin-top: 10px; color: var(--muted); font-size: 0.7rem; }
.energy-readout { display: grid; grid-template-columns: minmax(210px, 0.7fr) minmax(210px, 0.7fr) minmax(330px, 1.3fr); gap: 1px; overflow: hidden; background: var(--line); border: 1px solid var(--line); border-radius: var(--radius); }
.energy-readout article { min-width: 0; min-height: 150px; padding: 23px; background: var(--surface); }
.energy-value { display: grid; grid-template-columns: auto 1fr; align-content: center; gap: 6px 11px; }
.energy-value > svg, .telemetry-source > svg { color: var(--accent); }
.energy-value > span { align-self: center; color: var(--muted); font-size: 0.8rem; }
.energy-value strong { grid-column: 1 / -1; margin-top: 12px; font: 750 1.8rem/1 ui-monospace, "Cascadia Code", monospace; }
.energy-value small { grid-column: 1 / -1; margin-top: 3px; color: var(--muted); line-height: 1.4; }
.telemetry-source { display: flex; align-items: flex-start; gap: 15px; }
.telemetry-source > svg { flex: none; }
.telemetry-source strong { display: block; margin-bottom: 8px; }
.telemetry-source p { max-width: 55ch; margin: 0; color: var(--muted); font-size: 0.8rem; line-height: 1.5; }
.telemetry-footer { display: flex; justify-content: space-between; gap: 18px; color: var(--muted); font: 0.72rem/1.4 ui-monospace, "Cascadia Code", monospace; }
@media (max-width: 1200px) {
.adb-command-grid { grid-template-columns: 1fr; }
.drive-stage { grid-template-columns: 1fr 1.25fr; }
.gear-cluster { grid-column: 1 / -1; min-height: 130px; border-top: 1px solid var(--line); }
.gear-cluster strong { font-size: 4rem; }
.energy-readout { grid-template-columns: 1fr 1fr; }
.telemetry-source { grid-column: 1 / -1; }
}
@media (max-width: 767px) {
.sidebar nav .nav-item { flex: 1; width: auto; min-width: 0; }
.sidebar .nav-item.logout { width: 52px; min-width: 52px; }
.adb-install-panel, .shortcut-form { padding: 21px; }
.shortcut-fields { grid-template-columns: 1fr; }
.shortcut-fields .target-field { grid-column: auto; }
.shortcut-form-footer, .packages-toolbar { align-items: stretch; flex-direction: column; }
.shortcut-form-footer .secondary-button { width: 100%; }
.package-search { width: 100%; }
.packages-toolbar .icon-button { position: absolute; right: 40px; }
.package-list article { grid-template-columns: auto minmax(0, 1fr) auto; }
.package-kind { display: none; }
.drive-stage { min-height: 0; grid-template-columns: 1fr; padding: 22px; }
.wheel-visual { border: 0; border-top: 1px solid var(--line); border-bottom: 1px solid var(--line); }
.gear-cluster { grid-column: auto; border-top: 0; }
.energy-readout { grid-template-columns: 1fr; }
.telemetry-source { grid-column: auto; }
.telemetry-footer { flex-direction: column; }
}