saltylab-firmware/ui/gamepad_panel.css
sl-webui cbcae34b79 feat: WebUI gamepad teleoperation panel (Issue #598)
- Standalone ui/gamepad_panel.{html,js,css} — no build step
- Web Gamepad API integration: L-stick=linear, R-stick=angular
  - LT trigger scales speed down (fine control)
  - B/Circle button toggles E-stop; Start button resumes
  - Live raw axis bars and button state in sidebar
- Virtual dual joystick (left=drive, right=steer) via Pointer Capture API
  - Deadzone ring drawn on canvas; configurable 0–40%
  - Touch and mouse support
- WASD/Arrow keyboard input (W/S=forward/reverse, A/D=turn, Space=E-stop)
- Speed limiter sliders: linear (0–1.0 m/s), angular (0–2.0 rad/s)
- Configurable deadzone slider (0–40%)
- E-stop: latches zero-velocity command, blinking overlay, resume button
- Publishes geometry_msgs/Twist to /cmd_vel at 20 Hz via rosbridge WebSocket
- Input priority: gamepad > keyboard > virtual sticks
- Live command display (m/s, rad/s) with color feedback
- Pub rate display (Hz) in sidebar
- localStorage WS URL persistence, auto-reconnect on load
- Mobile-responsive: sidebar hidden ≤800px, right stick hidden ≤560px

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-14 14:58:41 -04:00

315 lines
7.9 KiB
CSS

/* gamepad_panel.css — Saltybot Gamepad Teleop (Issue #598) */
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--bg0: #050510;
--bg1: #070712;
--bg2: #0a0a1a;
--bd: #0c2a3a;
--bd2: #1e3a5f;
--dim: #374151;
--mid: #6b7280;
--base: #9ca3af;
--hi: #d1d5db;
--cyan: #06b6d4;
--green: #22c55e;
--amber: #f59e0b;
--red: #ef4444;
}
body {
font-family: 'Courier New', Courier, monospace;
font-size: 12px;
background: var(--bg0);
color: var(--base);
height: 100dvh;
display: flex;
flex-direction: column;
overflow: hidden;
}
/* ── Header ── */
#header {
display: flex;
align-items: center;
padding: 5px 12px;
background: var(--bg1);
border-bottom: 1px solid var(--bd);
flex-shrink: 0;
gap: 8px;
flex-wrap: wrap;
}
.logo { color: #f97316; font-weight: bold; letter-spacing: .15em; font-size: 13px; flex-shrink: 0; }
#conn-dot {
width: 8px; height: 8px; border-radius: 50%;
background: var(--dim); flex-shrink: 0; transition: background .3s;
}
#conn-dot.connected { background: var(--green); }
#conn-dot.error { background: var(--red); animation: blink 1s infinite; }
#gp-indicator {
display: flex; align-items: center; gap: 5px;
font-size: 10px; color: var(--mid);
}
#gp-dot {
width: 8px; height: 8px; border-radius: 50%;
background: var(--dim); flex-shrink: 0; transition: background .3s;
}
#gp-dot.active { background: var(--amber); }
@keyframes blink { 0%,100%{opacity:1} 50%{opacity:.3} }
#ws-input {
background: var(--bg2); border: 1px solid var(--bd2); border-radius: 4px;
color: #67e8f9; padding: 2px 7px; font-family: monospace; font-size: 11px; width: 185px;
}
#ws-input:focus { outline: none; border-color: var(--cyan); }
.hbtn {
padding: 2px 8px; border-radius: 4px; border: 1px solid var(--bd2);
background: var(--bg2); color: #67e8f9; font-family: monospace;
font-size: 10px; font-weight: bold; cursor: pointer; transition: background .15s;
white-space: nowrap;
}
.hbtn:hover { background: #0e4f69; }
.estop-btn {
border-color: #7f1d1d; background: #1c0505; color: #fca5a5;
padding: 3px 12px; font-size: 11px;
}
.estop-btn:hover { background: #3b0606; }
.estop-btn.active {
background: #7f1d1d; border-color: var(--red); color: #fff;
animation: blink .8s infinite;
}
.resume-btn {
border-color: #14532d; background: #052010; color: #86efac;
padding: 3px 12px; font-size: 11px;
}
.resume-btn:hover { background: #0a3a1a; }
/* ── Toolbar ── */
#toolbar {
display: flex;
align-items: center;
gap: 8px;
padding: 5px 12px;
background: var(--bg1);
border-bottom: 1px solid var(--bd);
flex-shrink: 0;
flex-wrap: wrap;
font-size: 10px;
}
.tsep { width: 1px; height: 16px; background: var(--bd2); }
.tlbl { color: var(--mid); letter-spacing: .08em; }
.tval { color: #67e8f9; min-width: 70px; font-family: monospace; }
.tslider {
-webkit-appearance: none;
width: 100px; height: 4px; border-radius: 2px;
background: var(--bd2); outline: none; cursor: pointer;
}
.tslider::-webkit-slider-thumb {
-webkit-appearance: none;
width: 12px; height: 12px; border-radius: 50%;
background: var(--cyan); cursor: pointer;
}
.tslider::-moz-range-thumb {
width: 12px; height: 12px; border-radius: 50%;
background: var(--cyan); cursor: pointer; border: none;
}
/* ── Main ── */
#main {
flex: 1;
display: grid;
grid-template-columns: 1fr 220px;
min-height: 0;
overflow: hidden;
}
/* ── Joystick area ── */
#joystick-area {
display: flex;
align-items: center;
justify-content: space-evenly;
gap: 12px;
padding: 16px;
overflow: hidden;
}
.stick-wrap {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
flex-shrink: 0;
}
.stick-label {
font-size: 9px; letter-spacing: .12em; color: var(--mid); text-transform: uppercase;
}
.stick-vals {
font-size: 10px; color: #67e8f9; font-family: monospace; min-width: 100px; text-align: center;
}
canvas {
display: block;
border-radius: 50%;
border: 1px solid var(--bd2);
touch-action: none;
cursor: grab;
}
canvas.active { cursor: grabbing; }
/* ── Center panel ── */
#center-panel {
flex: 1;
display: flex;
flex-direction: column;
gap: 8px;
max-width: 260px;
position: relative;
}
.cp-block {
background: var(--bg2);
border: 1px solid var(--bd2);
border-radius: 6px;
padding: 8px;
}
.cp-title {
font-size: 9px; font-weight: bold; letter-spacing: .12em;
color: #0891b2; text-transform: uppercase; margin-bottom: 5px;
}
.cp-row {
display: flex; justify-content: space-between;
padding: 2px 0; border-bottom: 1px solid var(--bd);
font-size: 10px;
}
.cp-row:last-child { border-bottom: none; }
.cp-lbl { color: var(--mid); }
.cp-val { color: var(--hi); font-family: monospace; }
/* Keyboard diagram */
.key-grid {
display: grid;
grid-template-columns: repeat(3, 28px);
grid-template-rows: repeat(3, 22px);
gap: 3px;
justify-content: center;
margin: 4px 0;
}
.key {
background: var(--bg1);
border: 1px solid var(--bd2);
border-radius: 3px;
display: flex; align-items: center; justify-content: center;
font-size: 9px; font-weight: bold; color: var(--mid);
transition: background .1s, color .1s, border-color .1s;
}
.key.pressed {
background: #0e4f69; border-color: var(--cyan); color: #fff;
}
.key-wide {
grid-column: 1 / 4;
width: 100%; height: 22px;
}
/* E-stop overlay */
#estop-panel {
position: absolute; inset: 0;
background: rgba(127,0,0,0.9);
border: 2px solid var(--red);
border-radius: 8px;
display: flex; flex-direction: column;
align-items: center; justify-content: center;
gap: 4px;
animation: blink .8s infinite;
}
.estop-text { font-size: 28px; font-weight: bold; color: #fca5a5; letter-spacing: .1em; }
.estop-sub { font-size: 11px; color: #fca5a5; letter-spacing: .2em; }
/* ── Sidebar ── */
#sidebar {
background: var(--bg1);
border-left: 1px solid var(--bd);
display: flex;
flex-direction: column;
overflow-y: auto;
padding: 8px;
gap: 8px;
font-size: 11px;
}
.sb-card {
background: var(--bg2);
border: 1px solid var(--bd2);
border-radius: 6px;
padding: 8px;
}
.sb-title {
font-size: 9px; font-weight: bold; letter-spacing: .12em;
color: #0891b2; text-transform: uppercase; margin-bottom: 6px;
}
.sb-row {
display: flex; justify-content: space-between;
padding: 2px 0; border-bottom: 1px solid var(--bd);
font-size: 10px;
}
.sb-row:last-child { border-bottom: none; }
.sb-lbl { color: var(--mid); }
.sb-val { color: var(--hi); font-family: monospace; }
/* Gamepad axis bars */
.gp-axis-row {
display: flex; align-items: center; gap: 4px;
font-size: 9px; color: var(--mid); margin-bottom: 3px;
}
.gp-axis-label { width: 30px; flex-shrink: 0; }
.gp-bar-track {
flex: 1; height: 6px; background: var(--bg0);
border: 1px solid var(--bd); border-radius: 3px; overflow: hidden;
position: relative;
}
.gp-bar-center {
position: absolute; top: 0; bottom: 0;
left: 50%; width: 1px; background: var(--dim);
}
.gp-bar-fill {
position: absolute; top: 0; bottom: 0;
background: var(--cyan); transition: none;
}
.gp-axis-val { width: 32px; text-align: right; color: var(--hi); flex-shrink: 0; }
.gp-btn-row {
display: flex; flex-wrap: wrap; gap: 3px;
margin-top: 6px;
}
.gp-btn-chip {
font-size: 8px; padding: 1px 5px; border-radius: 2px;
border: 1px solid var(--bd); background: var(--bg0); color: var(--dim);
transition: background .1s, color .1s;
}
.gp-btn-chip.pressed { background: var(--bd2); color: var(--hi); border-color: var(--cyan); }
/* ── Footer ── */
#footer {
background: var(--bg1); border-top: 1px solid var(--bd);
padding: 3px 12px;
display: flex; align-items: center; justify-content: space-between;
flex-shrink: 0; font-size: 10px; color: var(--dim);
}
/* ── Responsive ── */
@media (max-width: 800px) {
#main { grid-template-columns: 1fr; }
#sidebar { display: none; }
}
@media (max-width: 560px) {
#right-wrap { display: none; }
#center-panel { max-width: 100%; }
.tslider { width: 70px; }
}