initial commit
This commit is contained in:
@@ -0,0 +1,34 @@
|
|||||||
|
# Bottle Signal-Light UI
|
||||||
|
|
||||||
|
A Bottle-based browser interface for the supplied FastAPI signal-light service.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- Mutually exclusive green, yellow, and red controls
|
||||||
|
- Independent alert buzzer on/off control
|
||||||
|
- Demo mode on/off control
|
||||||
|
- All-off command
|
||||||
|
- Automatic status polling every 1.5 seconds
|
||||||
|
- Server-side proxy, so the browser does not need CORS access to FastAPI
|
||||||
|
|
||||||
|
## Run
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd signal_light_ui
|
||||||
|
python3 -m venv .venv
|
||||||
|
source .venv/bin/activate
|
||||||
|
pip install -r requirements.txt
|
||||||
|
|
||||||
|
export SIGNAL_LIGHT_API_URL=http://127.0.0.1:8000
|
||||||
|
python app.py
|
||||||
|
```
|
||||||
|
|
||||||
|
Open `http://<raspberry-pi-address>:8080`.
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
- `SIGNAL_LIGHT_API_URL` — FastAPI base URL, default `http://127.0.0.1:8000`
|
||||||
|
- `SIGNAL_LIGHT_TIMEOUT` — upstream timeout in seconds, default `3`
|
||||||
|
- `BOTTLE_HOST` — Bottle listen address, default `0.0.0.0`
|
||||||
|
- `BOTTLE_PORT` — Bottle port, default `8080`
|
||||||
|
- `BOTTLE_DEBUG` — set to `true` for debug/reloader
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
import os
|
||||||
|
from typing import Any, Dict, Tuple
|
||||||
|
|
||||||
|
import requests
|
||||||
|
from bottle import Bottle, HTTPError, TEMPLATE_PATH, request, response, run, static_file, template
|
||||||
|
|
||||||
|
APP_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
TEMPLATE_PATH.insert(0, os.path.join(APP_DIR, "templates"))
|
||||||
|
|
||||||
|
API_BASE_URL = os.getenv("SIGNAL_LIGHT_API_URL", "http://127.0.0.1:8000").rstrip("/")
|
||||||
|
REQUEST_TIMEOUT_SECONDS = float(os.getenv("SIGNAL_LIGHT_TIMEOUT", "3"))
|
||||||
|
|
||||||
|
app = Bottle()
|
||||||
|
|
||||||
|
|
||||||
|
@app.hook("after_request")
|
||||||
|
def add_security_headers() -> None:
|
||||||
|
response.set_header("X-Content-Type-Options", "nosniff")
|
||||||
|
response.set_header("X-Frame-Options", "DENY")
|
||||||
|
response.set_header("Referrer-Policy", "no-referrer")
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/")
|
||||||
|
def index():
|
||||||
|
return template("index", api_base_url=API_BASE_URL)
|
||||||
|
|
||||||
|
@app.get("/favicon.ico")
|
||||||
|
def serve_favicon():
|
||||||
|
return static_file("favicon.ico", root=os.path.join(APP_DIR, "static"))
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/static/<filepath:path>")
|
||||||
|
def serve_static(filepath: str):
|
||||||
|
return static_file(filepath, root=os.path.join(APP_DIR, "static"))
|
||||||
|
|
||||||
|
|
||||||
|
def call_signal_api(method: str, path: str) -> Tuple[Dict[str, Any], int]:
|
||||||
|
"""Call the signal-light service and normalize errors for the browser UI."""
|
||||||
|
url = f"{API_BASE_URL}{path}"
|
||||||
|
|
||||||
|
try:
|
||||||
|
upstream = requests.request(
|
||||||
|
method=method,
|
||||||
|
url=url,
|
||||||
|
timeout=REQUEST_TIMEOUT_SECONDS,
|
||||||
|
)
|
||||||
|
except requests.RequestException as exc:
|
||||||
|
return {
|
||||||
|
"ok": False,
|
||||||
|
"error": "Signal-light service is unavailable",
|
||||||
|
"detail": str(exc),
|
||||||
|
"upstream_url": url,
|
||||||
|
}, 502
|
||||||
|
|
||||||
|
try:
|
||||||
|
payload = upstream.json()
|
||||||
|
except ValueError:
|
||||||
|
payload = {
|
||||||
|
"ok": False,
|
||||||
|
"error": "Signal-light service returned a non-JSON response",
|
||||||
|
"detail": upstream.text[:500],
|
||||||
|
}
|
||||||
|
|
||||||
|
if not isinstance(payload, dict):
|
||||||
|
payload = {"data": payload}
|
||||||
|
|
||||||
|
payload.setdefault("ok", upstream.ok)
|
||||||
|
return payload, upstream.status_code
|
||||||
|
|
||||||
|
|
||||||
|
def proxy(method: str, path: str):
|
||||||
|
payload, status_code = call_signal_api(method, path)
|
||||||
|
response.content_type = "application/json"
|
||||||
|
response.status = status_code
|
||||||
|
return payload
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/ui-api/status")
|
||||||
|
def ui_status():
|
||||||
|
return proxy("GET", "/status")
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/ui-api/color/<color>")
|
||||||
|
def ui_color(color: str):
|
||||||
|
if color not in {"green", "yellow", "red"}:
|
||||||
|
raise HTTPError(400, "Unsupported color")
|
||||||
|
return proxy("POST", f"/{color}")
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/ui-api/alert/<state>")
|
||||||
|
def ui_alert(state: str):
|
||||||
|
if state == "on":
|
||||||
|
return proxy("POST", "/alert")
|
||||||
|
if state == "off":
|
||||||
|
return proxy("POST", "/alert/off")
|
||||||
|
raise HTTPError(400, "Unsupported alert state")
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/ui-api/demo/<state>")
|
||||||
|
def ui_demo(state: str):
|
||||||
|
if state == "on":
|
||||||
|
return proxy("POST", "/demo")
|
||||||
|
if state == "off":
|
||||||
|
return proxy("POST", "/demo/off")
|
||||||
|
raise HTTPError(400, "Unsupported demo state")
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/ui-api/off")
|
||||||
|
def ui_off():
|
||||||
|
return proxy("POST", "/off")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
host = os.getenv("BOTTLE_HOST", "0.0.0.0")
|
||||||
|
port = int(os.getenv("BOTTLE_PORT", "8080"))
|
||||||
|
debug = os.getenv("BOTTLE_DEBUG", "false").lower() == "true"
|
||||||
|
run(app=app, host=host, port=port, debug=debug, reloader=debug)
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
bottle==0.13.2
|
||||||
|
requests==2.32.3
|
||||||
+174
@@ -0,0 +1,174 @@
|
|||||||
|
:root {
|
||||||
|
color-scheme: dark;
|
||||||
|
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||||
|
background: #0b0f14;
|
||||||
|
color: #f4f7fb;
|
||||||
|
}
|
||||||
|
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
min-height: 100vh;
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at top left, rgba(70, 120, 255, .14), transparent 32rem),
|
||||||
|
#0b0f14;
|
||||||
|
}
|
||||||
|
|
||||||
|
button { font: inherit; }
|
||||||
|
|
||||||
|
.shell {
|
||||||
|
width: min(980px, calc(100% - 32px));
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 48px 0 64px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header, .section-title, .control-card, .footer-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header { margin-bottom: 24px; }
|
||||||
|
|
||||||
|
h1, h2, p { margin-top: 0; }
|
||||||
|
h1 { margin-bottom: 6px; font-size: clamp(2rem, 5vw, 3.8rem); }
|
||||||
|
h2 { margin-bottom: 8px; }
|
||||||
|
p { color: #a9b4c2; line-height: 1.55; }
|
||||||
|
|
||||||
|
.eyebrow {
|
||||||
|
margin-bottom: 8px;
|
||||||
|
color: #7ea2ff;
|
||||||
|
font-size: .76rem;
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: .14em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subtitle { margin-bottom: 0; }
|
||||||
|
code { color: #d7e2ff; }
|
||||||
|
|
||||||
|
.panel {
|
||||||
|
border: 1px solid #26313d;
|
||||||
|
border-radius: 24px;
|
||||||
|
background: rgba(20, 27, 35, .88);
|
||||||
|
box-shadow: 0 24px 80px rgba(0, 0, 0, .28);
|
||||||
|
padding: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 9px 14px;
|
||||||
|
font-size: .86rem;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
.badge--checking { background: #40391e; color: #f8d56b; }
|
||||||
|
.badge--online { background: #163b2a; color: #72e2a8; }
|
||||||
|
.badge--offline { background: #4a2025; color: #ff9da7; }
|
||||||
|
|
||||||
|
.section-title strong {
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #1a222d;
|
||||||
|
padding: 9px 14px;
|
||||||
|
text-transform: capitalize;
|
||||||
|
}
|
||||||
|
|
||||||
|
.traffic-light {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
gap: 20px;
|
||||||
|
margin-top: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lamp {
|
||||||
|
border: 1px solid #303b47;
|
||||||
|
border-radius: 20px;
|
||||||
|
background: #111820;
|
||||||
|
color: #dfe6ef;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 24px 12px 18px;
|
||||||
|
transition: transform .16s ease, border-color .16s ease, background .16s ease;
|
||||||
|
}
|
||||||
|
.lamp:hover { transform: translateY(-2px); border-color: #627086; }
|
||||||
|
.lamp:focus-visible, .toggle:focus-visible, .danger-button:focus-visible {
|
||||||
|
outline: 3px solid #7ea2ff;
|
||||||
|
outline-offset: 3px;
|
||||||
|
}
|
||||||
|
.lamp span {
|
||||||
|
display: block;
|
||||||
|
width: clamp(76px, 13vw, 130px);
|
||||||
|
aspect-ratio: 1;
|
||||||
|
margin: 0 auto 18px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #242c35;
|
||||||
|
box-shadow: inset 0 0 30px rgba(0,0,0,.8);
|
||||||
|
opacity: .24;
|
||||||
|
}
|
||||||
|
.lamp.is-active { background: #17202a; border-color: #738197; }
|
||||||
|
.lamp.is-active span { opacity: 1; }
|
||||||
|
.lamp--red.is-active span { background: #ff3b4f; box-shadow: 0 0 42px rgba(255,59,79,.75); }
|
||||||
|
.lamp--yellow.is-active span { background: #ffd23f; box-shadow: 0 0 42px rgba(255,210,63,.72); }
|
||||||
|
.lamp--green.is-active span { background: #31df78; box-shadow: 0 0 42px rgba(49,223,120,.72); }
|
||||||
|
.lamp:disabled, .toggle:disabled, .danger-button:disabled { cursor: wait; opacity: .62; }
|
||||||
|
|
||||||
|
.controls-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 20px;
|
||||||
|
margin-top: 20px;
|
||||||
|
}
|
||||||
|
.control-card p { margin-bottom: 0; }
|
||||||
|
|
||||||
|
.toggle {
|
||||||
|
min-width: 94px;
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
color: #dbe4ee;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.toggle__track {
|
||||||
|
display: block;
|
||||||
|
width: 66px;
|
||||||
|
height: 36px;
|
||||||
|
margin: 0 auto 8px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #3a4552;
|
||||||
|
padding: 4px;
|
||||||
|
transition: background .18s ease;
|
||||||
|
}
|
||||||
|
.toggle__thumb {
|
||||||
|
display: block;
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: white;
|
||||||
|
transition: transform .18s ease;
|
||||||
|
}
|
||||||
|
.toggle[aria-checked="true"] .toggle__track { background: #2dca70; }
|
||||||
|
.toggle[aria-checked="true"] .toggle__thumb { transform: translateX(30px); }
|
||||||
|
|
||||||
|
.footer-actions { margin-top: 20px; }
|
||||||
|
.danger-button {
|
||||||
|
border: 1px solid #a8404c;
|
||||||
|
border-radius: 14px;
|
||||||
|
background: #511e25;
|
||||||
|
color: #ffdfe2;
|
||||||
|
cursor: pointer;
|
||||||
|
font-weight: 800;
|
||||||
|
padding: 13px 18px;
|
||||||
|
}
|
||||||
|
.message { margin: 0; text-align: right; }
|
||||||
|
.message.is-error { color: #ff9da7; }
|
||||||
|
|
||||||
|
@media (max-width: 700px) {
|
||||||
|
.shell { padding-top: 24px; }
|
||||||
|
.header, .section-title, .control-card, .footer-actions { align-items: flex-start; }
|
||||||
|
.header, .control-card, .footer-actions { flex-direction: column; }
|
||||||
|
.controls-grid { grid-template-columns: 1fr; }
|
||||||
|
.traffic-light { gap: 10px; }
|
||||||
|
.panel { padding: 20px; border-radius: 18px; }
|
||||||
|
.lamp { padding-inline: 6px; }
|
||||||
|
.message { text-align: left; }
|
||||||
|
}
|
||||||
+137
@@ -0,0 +1,137 @@
|
|||||||
|
const state = {
|
||||||
|
activeColor: null,
|
||||||
|
alertActive: false,
|
||||||
|
demoActive: false,
|
||||||
|
busy: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
const lamps = [...document.querySelectorAll(".lamp")];
|
||||||
|
const alertToggle = document.getElementById("alertToggle");
|
||||||
|
const demoToggle = document.getElementById("demoToggle");
|
||||||
|
const allOffButton = document.getElementById("allOffButton");
|
||||||
|
const activeColorText = document.getElementById("activeColorText");
|
||||||
|
const alertToggleLabel = document.getElementById("alertToggleLabel");
|
||||||
|
const demoToggleLabel = document.getElementById("demoToggleLabel");
|
||||||
|
const connectionBadge = document.getElementById("connectionBadge");
|
||||||
|
const message = document.getElementById("message");
|
||||||
|
|
||||||
|
function setMessage(text, isError = false) {
|
||||||
|
message.textContent = text;
|
||||||
|
message.classList.toggle("is-error", isError);
|
||||||
|
}
|
||||||
|
|
||||||
|
function setConnection(online) {
|
||||||
|
connectionBadge.textContent = online ? "API online" : "API offline";
|
||||||
|
connectionBadge.className = `badge ${online ? "badge--online" : "badge--offline"}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function render() {
|
||||||
|
lamps.forEach((lamp) => {
|
||||||
|
const active = !state.demoActive && lamp.dataset.color === state.activeColor;
|
||||||
|
lamp.classList.toggle("is-active", active);
|
||||||
|
lamp.setAttribute("aria-pressed", String(active));
|
||||||
|
lamp.disabled = state.busy;
|
||||||
|
});
|
||||||
|
|
||||||
|
activeColorText.textContent = state.demoActive
|
||||||
|
? "Demo running"
|
||||||
|
: (state.activeColor || "Off");
|
||||||
|
|
||||||
|
alertToggle.setAttribute("aria-checked", String(state.alertActive));
|
||||||
|
alertToggleLabel.textContent = state.alertActive ? "On" : "Off";
|
||||||
|
alertToggle.disabled = state.busy;
|
||||||
|
|
||||||
|
demoToggle.setAttribute("aria-checked", String(state.demoActive));
|
||||||
|
demoToggleLabel.textContent = state.demoActive ? "On" : "Off";
|
||||||
|
demoToggle.disabled = state.busy;
|
||||||
|
|
||||||
|
allOffButton.disabled = state.busy;
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyApiState(payload) {
|
||||||
|
if (Object.hasOwn(payload, "active_color")) state.activeColor = payload.active_color;
|
||||||
|
if (Object.hasOwn(payload, "alert_active")) state.alertActive = Boolean(payload.alert_active);
|
||||||
|
if (Object.hasOwn(payload, "demo_active")) state.demoActive = Boolean(payload.demo_active);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function apiCall(path, options = {}) {
|
||||||
|
const result = await fetch(path, {
|
||||||
|
method: options.method || "GET",
|
||||||
|
headers: { "Accept": "application/json" },
|
||||||
|
});
|
||||||
|
const payload = await result.json().catch(() => ({}));
|
||||||
|
if (!result.ok) {
|
||||||
|
throw new Error(payload.error || payload.detail || `Request failed (${result.status})`);
|
||||||
|
}
|
||||||
|
return payload;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runAction(action, successMessage) {
|
||||||
|
if (state.busy) return;
|
||||||
|
state.busy = true;
|
||||||
|
render();
|
||||||
|
setMessage("Sending command…");
|
||||||
|
|
||||||
|
try {
|
||||||
|
const payload = await action();
|
||||||
|
applyApiState(payload);
|
||||||
|
setConnection(true);
|
||||||
|
setMessage(successMessage);
|
||||||
|
} catch (error) {
|
||||||
|
setConnection(false);
|
||||||
|
setMessage(error.message, true);
|
||||||
|
} finally {
|
||||||
|
state.busy = false;
|
||||||
|
render();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
lamps.forEach((lamp) => {
|
||||||
|
lamp.addEventListener("click", () => {
|
||||||
|
const color = lamp.dataset.color;
|
||||||
|
runAction(
|
||||||
|
() => apiCall(`/ui-api/color/${color}`, { method: "POST" }),
|
||||||
|
`${color[0].toUpperCase()}${color.slice(1)} light activated.`,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
alertToggle.addEventListener("click", () => {
|
||||||
|
const nextState = state.alertActive ? "off" : "on";
|
||||||
|
runAction(
|
||||||
|
() => apiCall(`/ui-api/alert/${nextState}`, { method: "POST" }),
|
||||||
|
`Alert turned ${nextState}.`,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
demoToggle.addEventListener("click", () => {
|
||||||
|
const nextState = state.demoActive ? "off" : "on";
|
||||||
|
runAction(
|
||||||
|
() => apiCall(`/ui-api/demo/${nextState}`, { method: "POST" }),
|
||||||
|
`Demo mode turned ${nextState}.`,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
allOffButton.addEventListener("click", () => {
|
||||||
|
runAction(
|
||||||
|
() => apiCall("/ui-api/off", { method: "POST" }),
|
||||||
|
"All outputs turned off.",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
async function refreshStatus({ quiet = false } = {}) {
|
||||||
|
if (state.busy) return;
|
||||||
|
try {
|
||||||
|
const payload = await apiCall("/ui-api/status");
|
||||||
|
applyApiState(payload);
|
||||||
|
setConnection(true);
|
||||||
|
if (!quiet) setMessage("Status synchronized.");
|
||||||
|
render();
|
||||||
|
} catch (error) {
|
||||||
|
setConnection(false);
|
||||||
|
if (!quiet) setMessage(error.message, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
refreshStatus();
|
||||||
|
setInterval(() => refreshStatus({ quiet: true }), 1500);
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
@@ -0,0 +1,22 @@
|
|||||||
|
# /etc/systemd/system/signal-light-ui.service
|
||||||
|
|
||||||
|
[Unit]
|
||||||
|
Description=Signal Light Bottle UI
|
||||||
|
After=network-online.target
|
||||||
|
Wants=network-online.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=pi
|
||||||
|
WorkingDirectory=/opt/signal-light-ui
|
||||||
|
|
||||||
|
# REST API endpoint
|
||||||
|
Environment=SIGNAL_LIGHT_API_URL=http://127.0.0.1:8000
|
||||||
|
|
||||||
|
ExecStart=/home/pi/signal_light_ui/.venv/bin/python app.py
|
||||||
|
|
||||||
|
Restart=on-failure
|
||||||
|
RestartSec=5
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>Signal Light Control</title>
|
||||||
|
<link rel="stylesheet" href="/static/app.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main class="shell">
|
||||||
|
<header class="header">
|
||||||
|
<div>
|
||||||
|
<p class="eyebrow">Raspberry Pi Control Panel</p>
|
||||||
|
<h1>Signal Light Controller</h1>
|
||||||
|
<p class="subtitle">REST API: <code>{{api_base_url}}</code></p>
|
||||||
|
</div>
|
||||||
|
<div id="connectionBadge" class="badge badge--checking">Checking…</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section class="panel" aria-labelledby="light-heading">
|
||||||
|
<div class="section-title">
|
||||||
|
<div>
|
||||||
|
<h2 id="light-heading">Light color</h2>
|
||||||
|
<p>Only one color can be active at a time.</p>
|
||||||
|
</div>
|
||||||
|
<strong id="activeColorText">Off</strong>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="traffic-light" aria-label="Current signal-light state">
|
||||||
|
<button class="lamp lamp--green" data-color="green" aria-label="Activate green light">
|
||||||
|
<span></span><b>Green</b>
|
||||||
|
</button>
|
||||||
|
<button class="lamp lamp--yellow" data-color="yellow" aria-label="Activate yellow light">
|
||||||
|
<span></span><b>Yellow</b>
|
||||||
|
</button>
|
||||||
|
<button class="lamp lamp--red" data-color="red" aria-label="Activate red light">
|
||||||
|
<span></span><b>Red</b>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="controls-grid">
|
||||||
|
<article class="panel control-card">
|
||||||
|
<div>
|
||||||
|
<p class="eyebrow">Independent output</p>
|
||||||
|
<h2>Alert buzzer</h2>
|
||||||
|
<p>The buzzer blinks independently of the selected light.</p>
|
||||||
|
</div>
|
||||||
|
<button id="alertToggle" class="toggle" type="button" role="switch" aria-checked="false">
|
||||||
|
<span class="toggle__track"><span class="toggle__thumb"></span></span>
|
||||||
|
<span id="alertToggleLabel">Off</span>
|
||||||
|
</button>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article class="panel control-card">
|
||||||
|
<div>
|
||||||
|
<p class="eyebrow">Automatic sequence</p>
|
||||||
|
<h2>Demo mode</h2>
|
||||||
|
<p>Cycles green → yellow → red → yellow.</p>
|
||||||
|
</div>
|
||||||
|
<button id="demoToggle" class="toggle" type="button" role="switch" aria-checked="false">
|
||||||
|
<span class="toggle__track"><span class="toggle__thumb"></span></span>
|
||||||
|
<span id="demoToggleLabel">Off</span>
|
||||||
|
</button>
|
||||||
|
</article>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="panel footer-actions">
|
||||||
|
<button id="allOffButton" class="danger-button" type="button">Turn everything off</button>
|
||||||
|
<p id="message" class="message" role="status" aria-live="polite">Ready.</p>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<script src="/static/app.js" defer></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Reference in New Issue
Block a user