feat(server): initial implementation

This commit captures the full server code accumulated across
several development sessions. It includes the following layers,
applied in roughly this order during development:

1. Base infrastructure
   - cmd/poc-server HTTP+WebSocket server, /health, /stats
   - internal/transport wire types (Envelope + all message types)
   - internal/catalog, internal/realtime, internal/stats
   - internal/storage/postgres with migrations 001-004
   - .env, .env.example, docker-compose, Dockerfile, scripts/

2. Swagger documentation
   - go get github.com/swaggo/http-swagger
   - swag init produces docs/docs.go
   - /swagger/index.html UI, /swagger/doc.json spec
   - annotations on health/stats/ws and catalog handlers

3. Races API
   - internal/races/{store,service,keyset,types}.go
   - migration 005_races.sql: finished_races, race_plans, race_queue
   - GET /api/races with keyset pagination, status filter
   - GET /api/races/upcoming
   - POST /api/races/queue/join, GET, DELETE
   - POST/GET /api/races/plans, DELETE /{id}
   - lobby.RaceMeta simplification (no host_id/host_name)

4. Races seeder
   - internal/races/seed with deterministic generator
   - --seed-races / --reset CLI flags in main.go
   - 30 finished + 5 live + 5 plans + 4 queue entries

5. Drivers and clans
   - internal/drivers, internal/clans (CRUD, validation)
   - migration 008_drivers_clans.sql
   - /api/clans and /api/drivers endpoints
   - 3-letter uppercase nickname/tag validation
   - lobby.DriverMeta: Nickname, AvatarURL, ClanID, ClanTag

6. Podium
   - internal/transport.RacePodiumEntry
   - lobby.SetDriverProfile for in-memory metadata sync
   - migration 007_podium.sql (podium JSONB column)
   - seeder populates top-3 per finished race

7. Live races persistence
   - migration 009_live_persistence.sql: live_races,
     live_race_drivers, lobby_drivers
   - internal/races/live_store.go: LiveStore with write-side
     mirror for lobby.Service mutations
   - Service.collectLive and Upcoming read from Postgres
   - main.go RestoreFromDB rehydrates lobby on startup

8. Unify live + finished into one races table
   - migration 010_unify_races.sql: rename finished_races to
     races, expand status CHECK, merge live rows
   - PgStore now hosts both write paths; LiveStore is a
     thin facade implementing lobby.Persistence
   - seeder resetAll drops only finished/cancelled rows and
     race_plans / race_queue / lobby_drivers

Each layer is consistent on its own; cross-layer changes are
visible in the file history. A future refactor may split this
commit into the per-stage boundaries listed above.
This commit is contained in:
x0gp
2026-06-22 22:01:09 +04:00
commit 978d36c505
71 changed files with 23500 additions and 0 deletions
+83
View File
@@ -0,0 +1,83 @@
# x0gp · PoC web client
Минимальный vanilla JS клиент для проверки PoC-сервера из этой же папки.
Без сборки, без зависимостей — открывается в браузере.
## Запуск
Вариант А — открыть файл напрямую:
```bash
# Windows
start web/index.html
# Linux
xdg-open web/index.html
# macOS
open web/index.html
```
> Некоторые браузеры не разрешают `WebSocket` к чужим origin со страницы,
> открытой по `file://`. Если что — поднимите мини-сервер (вариант Б).
Вариант Б — раздать через тот же poc-server:
В `cmd/poc-server/main.go` есть TODO — добавить `http.FileServer` для `web/`.
После этого клиент будет доступен по `http://localhost:8080/`.
Вариант В`python -m http.server`:
```bash
cd server/web
python -m http.server 5173
# открыть http://localhost:5173/
```
## Подключение к серверу
1. Запустите `poc-server` (см. корневой `server/README.md`).
2. В UI клиента проверьте `WS URL` — по умолчанию `ws://localhost:8080/ws`.
3. Укажите `driver_id` (например `driver-1`).
4. **Connect****Join race**.
5. Управление:
- `W` / `↑` — газ
- `S` / `↓` — тормоз
- `A` / `←` — руль влево
- `D` / `→` — руль вправо
- `Space` — ручник
6. На canvas появится ваша машинка (синяя) и машинки других подключённых
драйверов (оранжевые).
## Что смотреть
- **tick Hz** в верхней панели — частота приходящих `race_snapshot`.
Должно держаться ~30 Hz.
- **rtt** — round-trip ping/pong, в PoC без сети это 0–2 ms.
- **seq** — растёт с каждым отправленным сообщением.
- **Телеметрия** — speed/heading/lap/sector вашей машинки.
- **Лог** — все входящие/исходящие сообщения (кроме ping/pong).
## Что PoC делает
- Открывает WebSocket, шлёт `client_hello`.
- По кнопке— `join_race` / `leave_race`.
- Каждые 33 ms (≈30 Hz) шлёт `input_state` (throttle/brake/steer/handbrake).
- Каждые 1000 ms шлёт `ping` для измерения RTT.
- Получает `race_snapshot` и рисует все машинки на канвасе.
- Получает `input_ack`, `race_event`, `correction`, `error` и логирует их.
## Что PoC НЕ делает
- Нет предиктивного сглаживания (interpolation/extrapolation) — увидите
«дёрганье», это нормально для PoC.
- Нет клиент-сайд предикции — добавим, когда появится настоящая модель физики.
- Нет тач-управления для мобильных — клавиатура только.
- Нет авторизации, биллинга, чата — это всё вне PoC.
## Что дальше
- Когда `proto/` будет сгенерирован в Go и JS — заменим JSON на protobuf.
- Подключим `gRPC-Web` или бинарный WS-фрейминг.
- Добавим предиктивный слой и интерполяцию.
- Добавим мобильный layout (touch steering + pedals).
+106
View File
@@ -0,0 +1,106 @@
<!doctype html>
<html lang="ru">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>x0gp · PoC client</title>
<link rel="stylesheet" href="style.css" />
</head>
<body>
<header class="topbar">
<div class="brand">
<span class="logo">x0gp</span>
<span class="tag">PoC client</span>
</div>
<div class="conn" id="connState">disconnected</div>
<div class="hud-top" id="hudTop">
<span>tick: <b id="tickHz">--</b> Hz</span>
<span>rtt: <b id="rtt">--</b> ms</span>
<span>seq: <b id="seq">0</b></span>
</div>
</header>
<main class="layout">
<!-- Левая колонка: трасса -->
<section class="track-wrap">
<canvas id="track" width="640" height="420"></canvas>
<div class="legend">
<span><i class="dot me"></i> вы</span>
<span><i class="dot other"></i> другие</span>
<span><i class="dot ghost"></i> correction (если будет)</span>
</div>
</section>
<!-- Правая колонка: управление и телеметрия -->
<aside class="controls">
<div class="card">
<h3>Подключение</h3>
<label>WS URL
<input id="wsUrl" type="text" value="ws://localhost:8080/ws" />
</label>
<label>Ник / driver_id
<input id="driverId" type="text" placeholder="driver-1" value="driver-1" />
</label>
<label>Race ID
<input id="raceId" type="text" value="demo-race" />
</label>
<div class="btn-row">
<button id="btnConnect" class="btn primary">Connect</button>
<button id="btnDisconnect" class="btn" disabled>Disconnect</button>
</div>
<div class="btn-row">
<button id="btnJoin" class="btn" disabled>Join race</button>
<button id="btnLeave" class="btn" disabled>Leave race</button>
</div>
</div>
<div class="card">
<h3>Управление</h3>
<p class="hint">
W/S — газ/тормоз, A/D — руль, Space — ручник, R — сброс позиции (debug)
</p>
<div class="pedals">
<div class="bar">
<span>throttle</span>
<div class="bar-track"><div id="barThrottle" class="bar-fill green"></div></div>
<b id="lblThrottle">0.00</b>
</div>
<div class="bar">
<span>brake</span>
<div class="bar-track"><div id="barBrake" class="bar-fill red"></div></div>
<b id="lblBrake">0.00</b>
</div>
<div class="bar">
<span>steer</span>
<div class="bar-track"><div id="barSteer" class="bar-fill blue"></div></div>
<b id="lblSteer">0.00</b>
</div>
</div>
<div class="wheel">
<div class="wheel-disc" id="wheelDisc"></div>
</div>
</div>
<div class="card">
<h3>Телеметрия</h3>
<dl class="tel">
<dt>speed</dt><dd id="tSpeed">0.00 m/s</dd>
<dt>heading</dt><dd id="tHeading">0.0°</dd>
<dt>lap</dt><dd id="tLap">0</dd>
<dt>sector</dt><dd id="tSector">-</dd>
<dt>pos</dt><dd id="tPos">(0.00, 0.00)</dd>
<dt>server_time</dt><dd id="tTsm">--</dd>
<dt>cars in race</dt><dd id="tCars">0</dd>
</dl>
</div>
<div class="card log-card">
<h3>Лог</h3>
<pre id="log" class="log"></pre>
</div>
</aside>
</main>
<script src="main.js"></script>
</body>
</html>
+468
View File
@@ -0,0 +1,468 @@
// x0gp · PoC web client (vanilla JS)
// Протокол совпадает с internal/transport/codec.go (envelope + payload).
// В PoC сервер говорит по JSON — потом заменим на binary protobuf.
(function () {
'use strict';
// -------- DOM --------
const $ = (id) => document.getElementById(id);
const ui = {
wsUrl: $('wsUrl'),
driverId: $('driverId'),
raceId: $('raceId'),
btnConnect: $('btnConnect'),
btnDisconnect: $('btnDisconnect'),
btnJoin: $('btnJoin'),
btnLeave: $('btnLeave'),
connState: $('connState'),
tickHz: $('tickHz'),
rtt: $('rtt'),
seq: $('seq'),
canvas: $('track'),
log: $('log'),
barThrottle: $('barThrottle'),
barBrake: $('barBrake'),
barSteer: $('barSteer'),
lblThrottle: $('lblThrottle'),
lblBrake: $('lblBrake'),
lblSteer: $('lblSteer'),
wheelDisc: $('wheelDisc'),
tSpeed: $('tSpeed'),
tHeading: $('tHeading'),
tLap: $('tLap'),
tSector: $('tSector'),
tPos: $('tPos'),
tTsm: $('tTsm'),
tCars: $('tCars'),
};
// -------- State --------
const state = {
ws: null,
connected: false,
joined: false,
mySessionId: null,
myCarId: null,
inputSeq: 0,
inputSendAt: 0,
lastPingAt: 0,
lastPongRtt: 0,
lastSnapshot: null,
lastSnapshotAt: 0,
snapshotCount: 0,
snapshotWindow: [],
keys: new Set(),
input: { throttle: 0, brake: 0, steer: 0, handbrake: false, buttons: 0 },
pingTimer: null,
inputTimer: null,
slotHint: 0, // сервер выдаёт слот; 0 по умолчанию
};
const ctx = ui.canvas.getContext('2d');
const CANVAS_W = ui.canvas.width;
const CANVAS_H = ui.canvas.height;
// Простая кольцевая трасса для PoC (центр в (CANVAS_W/2, CANVAS_H/2))
const TRACK = {
cx: CANVAS_W / 2,
cy: CANVAS_H / 2,
rx: 260, // внешний радиус по X
ry: 150, // внешний радиус по Y
lane: 40, // ширина трассы в пикселях
};
// Коэффициент пересчёта: 1 метр = ~ 20 пикселей (для PoC)
const M2PX = 20;
// -------- Logging --------
function log(msg, kind) {
const t = new Date().toLocaleTimeString();
const line = document.createElement('div');
line.className = 'l-' + (kind || 'info');
line.textContent = `[${t}] ${msg}`;
ui.log.appendChild(line);
ui.log.scrollTop = ui.log.scrollHeight;
// обрезаем лог до 500 строк
while (ui.log.childNodes.length > 500) ui.log.removeChild(ui.log.firstChild);
}
// -------- Helpers --------
function setConn(text, cls) {
ui.connState.textContent = text;
ui.connState.className = 'conn ' + (cls || '');
}
function clamp(v, a, b) { return v < a ? a : v > b ? b : v; }
function deadzone(v, d) { return Math.abs(v) < d ? 0 : v; }
// -------- Input handling (keyboard) --------
// W/S — газ/тормоз; A/D — руль; Space — ручник; R — reset (debug)
function pollInput() {
const k = state.keys;
// Throttle & brake — дискретные «клавиши», но мы шлём 1.0 при нажатой.
// Для плавности можно отдавать ramp-rate — в PoC держим 0/1.
const throttle = (k.has('w') || k.has('arrowup')) ? 1.0 : 0.0;
const brake = (k.has('s') || k.has('arrowdown')) ? 1.0 : 0.0;
// Steer — это «удержание» крайнего положения. 0 если ничего не нажато.
let steer = 0.0;
if (k.has('a') || k.has('arrowleft')) steer -= 1.0;
if (k.has('d') || k.has('arrowright')) steer += 1.0;
steer = clamp(steer, -1, 1);
const handbrake = k.has(' ');
state.input.throttle = throttle;
state.input.brake = brake;
state.input.steer = steer;
state.input.handbrake = handbrake;
// Update UI bars
ui.lblThrottle.textContent = throttle.toFixed(2);
ui.lblBrake.textContent = brake.toFixed(2);
ui.lblSteer.textContent = steer.toFixed(2);
// bars: позитивная шкала; для руля — симметричная вокруг центра
ui.barThrottle.style.left = '0%';
ui.barThrottle.style.width = (throttle * 100).toFixed(1) + '%';
ui.barBrake.style.left = '0%';
ui.barBrake.style.width = (brake * 100).toFixed(1) + '%';
// руль: центр = 50%
const steerPct = (steer + 1) * 50;
ui.barSteer.style.left = (steerPct - 1) + '%';
ui.barSteer.style.width = '2%';
ui.wheelDisc.style.transform = `rotate(${steer * 90}deg)`;
}
// -------- Send helpers --------
function sendEnvelope(env) {
if (!state.ws || state.ws.readyState !== WebSocket.OPEN) return;
const data = JSON.stringify(env);
try {
state.ws.send(data);
if (env.type !== 'ping') log('→ ' + env.type, 'tx');
} catch (e) {
log('send error: ' + e.message, 'error');
}
}
function sendClientHello() {
sendEnvelope({
type: 'client_hello',
seq: ++state.inputSeq,
tsMs: Date.now(),
payload: {
version: 'x0gp/0.1',
token: '',
locale: (navigator.language || 'en').slice(0, 8),
client_id: '',
},
});
}
function sendJoinRace() {
const name = ui.driverId.value.trim() || 'driver-anon';
sendEnvelope({
type: 'join_race',
seq: ++state.inputSeq,
tsMs: Date.now(),
payload: {
race_id: ui.raceId.value.trim() || 'demo-race',
car_slot: state.slotHint,
car_name: name,
},
});
}
function sendLeaveRace() {
sendEnvelope({
type: 'leave_race',
seq: ++state.inputSeq,
tsMs: Date.now(),
payload: {},
});
}
function sendInputState() {
if (!state.joined) return;
state.inputSendAt = performance.now();
sendEnvelope({
type: 'input_state',
seq: ++state.inputSeq,
tsMs: Date.now(),
payload: {
steering: state.input.steer,
throttle: state.input.throttle,
brake: state.input.brake,
gear: 1,
buttons: state.input.buttons,
},
});
}
function sendPing() {
state.lastPingAt = performance.now();
sendEnvelope({
type: 'ping',
seq: ++state.inputSeq,
tsMs: Date.now(),
payload: {
client_ts_ms: Date.now(),
},
});
}
// -------- Receive --------
function handleServerEnvelope(env) {
switch (env.type) {
case 'server_hello': {
const p = env.payload || {};
state.mySessionId = p.session_id || p.sessionId;
const cfg = p.config || {};
log('server hello: session=' + state.mySessionId +
' tick=' + (cfg.tick_rate_hz || '?') + 'Hz' +
' snapshot=' + (cfg.snapshot_rate_hz || '?') + 'Hz', 'meta');
break;
}
case 'race_snapshot': {
handleSnapshot(env.payload);
break;
}
case 'input_ack': {
// сервер подтверждает вход по seq; можно использовать для RTT-оценки
const seq = env.seq;
log('input_ack seq=' + seq, 'rx');
break;
}
case 'pong': {
const now = performance.now();
state.lastPongRtt = Math.round(now - state.lastPingAt);
ui.rtt.textContent = String(state.lastPongRtt);
break;
}
case 'race_event': {
const p = env.payload || {};
log('event: ' + (p.eventType || '?') + ' ' + JSON.stringify(p), 'warn');
break;
}
case 'error': {
log('server error: ' + JSON.stringify(env.payload), 'error');
break;
}
case 'correction': {
log('correction: ' + JSON.stringify(env.payload), 'warn');
break;
}
default:
log('unknown type: ' + env.type, 'warn');
}
}
function handleSnapshot(payload) {
if (!payload) return;
state.lastSnapshot = payload;
state.lastSnapshotAt = performance.now();
state.snapshotCount++;
const now = performance.now();
state.snapshotWindow.push(now);
while (state.snapshotWindow.length && now - state.snapshotWindow[0] > 1000) {
state.snapshotWindow.shift();
}
ui.tickHz.textContent = String(state.snapshotWindow.length);
ui.seq.textContent = String(env_seq_current());
ui.tTsm.textContent = String(payload.ts_ms || '--');
ui.tCars.textContent = String((payload.cars || []).length);
// Найти свою машинку по driver_name (== driverId из формы)
const myName = ui.driverId.value.trim();
const me = (payload.cars || []).find((c) => c.driver_name === myName);
if (me) {
state.myCarId = me.id;
ui.tSpeed.textContent = (me.speed || 0).toFixed(2) + ' m/s';
ui.tHeading.textContent = ((me.heading || 0) * 180 / Math.PI).toFixed(1) + '°';
ui.tLap.textContent = String(me.lap || 0);
ui.tSector.textContent = String(me.sector != null ? me.sector : '-');
ui.tPos.textContent = `(${(me.x || 0).toFixed(2)}, ${(me.y || 0).toFixed(2)})`;
}
drawSnapshot(payload);
}
function env_seq_current() { return state.inputSeq; }
// -------- Rendering --------
function drawSnapshot(snap) {
ctx.clearRect(0, 0, CANVAS_W, CANVAS_H);
// фон-сетка
ctx.strokeStyle = '#1f2630';
ctx.lineWidth = 1;
for (let x = 0; x < CANVAS_W; x += 40) {
ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, CANVAS_H); ctx.stroke();
}
for (let y = 0; y < CANVAS_H; y += 40) {
ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(CANVAS_W, y); ctx.stroke();
}
// Кольцевая трасса (овал). Внешний радиус = rx, ry; внутренний = rx-lane, ry-lane.
ctx.lineWidth = TRACK.lane;
ctx.strokeStyle = '#2d333b';
ctx.beginPath();
ctx.ellipse(TRACK.cx, TRACK.cy, TRACK.rx, TRACK.ry, 0, 0, Math.PI * 2);
ctx.stroke();
// Центральная разметка
ctx.lineWidth = 2;
ctx.strokeStyle = '#484f58';
ctx.setLineDash([8, 8]);
ctx.beginPath();
ctx.ellipse(TRACK.cx, TRACK.cy,
TRACK.rx - TRACK.lane / 2, TRACK.ry - TRACK.lane / 2, 0, 0, Math.PI * 2);
ctx.stroke();
ctx.setLineDash([]);
// Стартовая линия
ctx.strokeStyle = '#f0883e';
ctx.lineWidth = 3;
ctx.beginPath();
ctx.moveTo(TRACK.cx, TRACK.cy - TRACK.ry);
ctx.lineTo(TRACK.cx, TRACK.cy - TRACK.ry + TRACK.lane);
ctx.stroke();
// Машинки. Мировые координаты (метры) → пиксели:
// центр трассы в (0,0), X — вправо, Y — вниз (как на канвасе).
// В PoC считаем, что 1 метр = M2PX пикселей, начало трассы в (0,0).
// TODO: после появления настоящей карты — масштабировать под неё.
const myDriver = ui.driverId.value.trim();
for (const car of (snap.cars || [])) {
const isMe = car.driver_name === myDriver;
const px = TRACK.cx + (car.x || 0) * M2PX;
const py = TRACK.cy + (car.y || 0) * M2PX;
ctx.save();
ctx.translate(px, py);
ctx.rotate(car.heading || 0);
ctx.fillStyle = isMe ? '#58a6ff' : '#f0883e';
ctx.strokeStyle = '#0e1117';
ctx.lineWidth = 1;
// корпус машинки
ctx.fillRect(-10, -5, 20, 10);
ctx.strokeRect(-10, -5, 20, 10);
// направляющая полоска
ctx.fillStyle = isMe ? '#a5c6ff' : '#ffc685';
ctx.fillRect(8, -1, 4, 2);
ctx.restore();
// метка driver_name
ctx.fillStyle = '#e6edf3';
ctx.font = '11px ui-monospace, Menlo, Consolas, monospace';
ctx.textAlign = 'center';
ctx.fillText((car.driver_name || '?') + (isMe ? ' ★' : ''), px, py - 14);
}
}
// -------- Connection --------
function connect() {
const url = ui.wsUrl.value.trim();
log('connecting to ' + url, 'info');
setConn('connecting…', 'connecting');
const ws = new WebSocket(url);
state.ws = ws;
ws.addEventListener('open', () => {
state.connected = true;
setConn('connected', 'connected');
log('ws open', 'info');
ui.btnConnect.disabled = true;
ui.btnDisconnect.disabled = false;
ui.btnJoin.disabled = false;
sendClientHello();
// Timers: input 30Hz, ping 1Hz
if (state.inputTimer) clearInterval(state.inputTimer);
state.inputTimer = setInterval(() => {
pollInput();
sendInputState();
}, 33); // ~30 Hz (snapshots — 30 Hz, чтобы не заливать канал)
if (state.pingTimer) clearInterval(state.pingTimer);
state.pingTimer = setInterval(sendPing, 1000);
});
ws.addEventListener('message', (ev) => {
let env;
try { env = JSON.parse(ev.data); }
catch (e) { log('bad json: ' + e.message, 'error'); return; }
if (env.type !== 'pong') log('← ' + env.type, 'rx');
handleServerEnvelope(env);
});
ws.addEventListener('close', (ev) => {
state.connected = false;
state.joined = false;
setConn('disconnected', '');
log('ws close code=' + ev.code + ' reason=' + (ev.reason || ''), 'warn');
ui.btnConnect.disabled = false;
ui.btnDisconnect.disabled = true;
ui.btnJoin.disabled = true;
ui.btnLeave.disabled = true;
if (state.inputTimer) { clearInterval(state.inputTimer); state.inputTimer = null; }
if (state.pingTimer) { clearInterval(state.pingTimer); state.pingTimer = null; }
});
ws.addEventListener('error', (ev) => {
log('ws error', 'error');
setConn('error', 'error');
});
}
function disconnect() {
if (state.ws) state.ws.close(1000, 'client-bye');
}
function joinRace() {
if (!state.connected) return;
state.joined = true;
ui.btnJoin.disabled = true;
ui.btnLeave.disabled = false;
sendJoinRace();
}
function leaveRace() {
if (!state.connected) return;
state.joined = false;
ui.btnJoin.disabled = false;
ui.btnLeave.disabled = true;
sendLeaveRace();
}
// -------- Keyboard --------
function keyName(e) {
if (e.key === ' ') return ' ';
if (e.key === 'ArrowUp') return 'arrowup';
if (e.key === 'ArrowDown') return 'arrowdown';
if (e.key === 'ArrowLeft') return 'arrowleft';
if (e.key === 'ArrowRight') return 'arrowright';
return e.key.toLowerCase();
}
window.addEventListener('keydown', (e) => {
const k = keyName(e);
// Не даём странице скроллить на стрелках/пробеле, если фокус на инпутах — пропускаем.
const tag = (e.target && e.target.tagName) || '';
if (tag === 'INPUT' || tag === 'TEXTAREA') return;
if (['arrowup','arrowdown','arrowleft','arrowright',' '].includes(k)) e.preventDefault();
state.keys.add(k);
});
window.addEventListener('keyup', (e) => {
state.keys.delete(keyName(e));
});
window.addEventListener('blur', () => state.keys.clear());
// -------- Buttons --------
ui.btnConnect.addEventListener('click', connect);
ui.btnDisconnect.addEventListener('click', disconnect);
ui.btnJoin.addEventListener('click', joinRace);
ui.btnLeave.addEventListener('click', leaveRace);
// -------- Boot --------
setConn('disconnected', '');
log('x0gp PoC client ready. Press Connect.', 'meta');
})();
+267
View File
@@ -0,0 +1,267 @@
/* x0gp · PoC web client stylesheet
Минималистичный тёмный UI. Без зависимостей, без сборки. */
* { box-sizing: border-box; }
html, body { margin: 0; padding: 0; height: 100%; }
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen,
Ubuntu, Cantarell, "Helvetica Neue", Arial, sans-serif;
background: #0e1117;
color: #e6edf3;
font-size: 14px;
line-height: 1.4;
}
.topbar {
display: flex;
align-items: center;
gap: 18px;
padding: 10px 16px;
background: #161b22;
border-bottom: 1px solid #30363d;
}
.brand .logo {
font-weight: 700;
letter-spacing: 0.5px;
color: #58a6ff;
font-size: 18px;
}
.brand .tag {
margin-left: 8px;
padding: 2px 6px;
font-size: 11px;
background: #1f6feb;
color: #fff;
border-radius: 4px;
text-transform: uppercase;
}
.conn {
padding: 4px 10px;
border-radius: 4px;
background: #6e7681;
color: #fff;
font-size: 12px;
}
.conn.connected { background: #2ea043; }
.conn.connecting { background: #d29922; }
.conn.error { background: #f85149; }
.hud-top {
margin-left: auto;
display: flex;
gap: 14px;
font-size: 12px;
color: #8b949e;
}
.hud-top b { color: #e6edf3; font-weight: 600; }
.layout {
display: grid;
grid-template-columns: 1fr 360px;
gap: 16px;
padding: 16px;
align-items: start;
}
/* Track canvas */
.track-wrap {
background: #161b22;
border: 1px solid #30363d;
border-radius: 8px;
padding: 12px;
}
.track-wrap canvas {
width: 100%;
height: auto;
display: block;
background: #0a0d12;
border-radius: 4px;
image-rendering: pixelated;
}
.legend {
display: flex;
gap: 16px;
margin-top: 8px;
font-size: 12px;
color: #8b949e;
}
.legend i.dot {
display: inline-block;
width: 10px;
height: 10px;
border-radius: 50%;
margin-right: 4px;
vertical-align: middle;
}
.dot.me { background: #58a6ff; }
.dot.other { background: #f0883e; }
.dot.ghost { background: #d29922; }
/* Controls aside */
.controls { display: flex; flex-direction: column; gap: 12px; }
.card {
background: #161b22;
border: 1px solid #30363d;
border-radius: 8px;
padding: 12px;
}
.card h3 {
margin: 0 0 10px;
font-size: 13px;
text-transform: uppercase;
letter-spacing: 0.5px;
color: #8b949e;
}
.card label {
display: block;
font-size: 12px;
color: #8b949e;
margin-bottom: 8px;
}
.card input[type=text] {
display: block;
width: 100%;
margin-top: 4px;
padding: 6px 8px;
background: #0e1117;
border: 1px solid #30363d;
border-radius: 4px;
color: #e6edf3;
font-family: inherit;
font-size: 13px;
}
.card input[type=text]:focus {
outline: none;
border-color: #58a6ff;
}
.btn-row {
display: flex;
gap: 6px;
margin-top: 8px;
}
.btn {
flex: 1;
padding: 6px 10px;
background: #21262d;
border: 1px solid #30363d;
border-radius: 4px;
color: #e6edf3;
cursor: pointer;
font-size: 12px;
font-family: inherit;
}
.btn:hover:not(:disabled) { background: #30363d; }
.btn:disabled { opacity: 0.4; cursor: not-allowed; }
.btn.primary { background: #238636; border-color: #2ea043; }
.btn.primary:hover:not(:disabled) { background: #2ea043; }
.hint {
margin: 0 0 8px;
font-size: 11px;
color: #8b949e;
}
/* Pedals / bars */
.pedals { display: flex; flex-direction: column; gap: 6px; }
.bar {
display: grid;
grid-template-columns: 60px 1fr 44px;
gap: 8px;
align-items: center;
font-size: 12px;
}
.bar-track {
position: relative;
height: 12px;
background: #0e1117;
border: 1px solid #30363d;
border-radius: 3px;
overflow: hidden;
}
.bar-fill {
position: absolute;
top: 0; bottom: 0;
left: 50%;
width: 0%;
transition: left 60ms linear, width 60ms linear;
}
.bar-fill.green { background: #2ea043; }
.bar-fill.red { background: #f85149; }
.bar-fill.blue { background: #58a6ff; }
.bar b {
text-align: right;
font-variant-numeric: tabular-nums;
color: #e6edf3;
font-size: 12px;
}
/* Steering wheel visual */
.wheel {
display: flex;
justify-content: center;
margin-top: 10px;
}
.wheel-disc {
width: 90px;
height: 90px;
border-radius: 50%;
background: radial-gradient(circle at center, #30363d 0%, #21262d 70%);
border: 2px solid #484f58;
position: relative;
transition: transform 80ms linear;
}
.wheel-disc::after {
content: '';
position: absolute;
top: 4px;
left: 50%;
width: 4px;
height: 14px;
background: #f0883e;
transform: translateX(-50%);
border-radius: 2px;
}
/* Telemetry */
.tel {
display: grid;
grid-template-columns: 100px 1fr;
gap: 4px 12px;
margin: 0;
font-size: 12px;
}
.tel dt { color: #8b949e; }
.tel dd {
margin: 0;
font-variant-numeric: tabular-nums;
color: #e6edf3;
}
/* Log */
.log-card .log {
margin: 0;
padding: 8px;
background: #0a0d12;
border: 1px solid #30363d;
border-radius: 4px;
height: 160px;
overflow-y: auto;
font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas,
"Liberation Mono", monospace;
font-size: 11px;
line-height: 1.45;
color: #c9d1d9;
white-space: pre-wrap;
word-break: break-word;
}
.log .l-info { color: #58a6ff; }
.log .l-warn { color: #d29922; }
.log .l-error { color: #f85149; }
.log .l-rx { color: #2ea043; }
.log .l-tx { color: #f0883e; }
.log .l-meta { color: #8b949e; }
@media (max-width: 900px) {
.layout { grid-template-columns: 1fr; }
}