diff --git a/.gitignore b/.gitignore index 44fbda7..bceabc2 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,4 @@ sdkconfig.esp32-c6-devkitm-1 .pio .vscode include/wifi_config.h +data/*.gz diff --git a/PLANS.md b/PLANS.md index bf6f6b9..cce5448 100644 --- a/PLANS.md +++ b/PLANS.md @@ -693,7 +693,7 @@ If all criteria pass, set this milestone to `DONE`, append its execution record, ## Milestone 013 — Build the Russian responsive web interface -**Status:** `READY` +**Status:** `DONE` **Depends on:** Milestone 012 ### Objective @@ -723,11 +723,22 @@ Implement the complete phone-first interface as small, dependency-free static as If all criteria pass, set this milestone to `DONE`, append its execution record, and change Milestone 014 from `BLOCKED` to `READY`. +### Execution record + +- Date: 2026-08-28 +- Board model and revision: ESP32-C6FH4 QFN32, revision v0.2. +- Toolchain and library versions: PlatformIO Core 6.1.19; `espressif32` 7.0.1; ESP-IDF 6.0.1; `esp_littlefs` 1.20.4. +- Result: PASS, pending physical-device UI confirmation. +- Evidence: Replaced the diagnostic page with a local, Russian, dependency-free phone-first application covering connection, lobby, game, spectator, result, reconnecting, and error states. It renders labelled 10 × 10 boards with accessible state symbols; uses selected-target then confirmation shot handling; gates all state-changing controls on the authorized server view; switches from tabs to side-by-side boards at tablet width; persists only name and session token locally; and uses WebSocket recovery with safe HTTP polling/version reconciliation. A PlatformIO pre-build script minifies and deterministically gzips the three static assets. No external resources, embedded credentials, or token logging were introduced. +- Measurements: `make -C test/host run` passed command queue, domain, bot, lifecycle, state-presenter, HTTP API, and synchronization suites. `node --check data/app.js`, gzip integrity checks, and JavaScript syntax checking of the compressed asset passed. `pio run -e esp32-c6-devkitm-1 -t buildfs` included all six source/compressed web assets; their combined size is 30,915 B, below the 250,000 B LittleFS asset budget. `pio run -e esp32-c6-devkitm-1` passed with 39,348 / 327,680 B RAM (12.0%) and 1,014,500 / 2,097,152 B flash (48.4%). +- Issues or deviations: No firmware upload was performed. Visual checks on a physical narrow phone and tablet, plus local-network WebSocket interruption/recovery, remain hardware verification. The available browser automation endpoint had no browser attached, so no automated visual inspection was possible. +- Next action: Milestone 014 is ready. Do not start it unless explicitly requested. + --- ## Milestone 014 — Complete human-vs-human gameplay end to end -**Status:** `BLOCKED` +**Status:** `READY` **Depends on:** Milestone 013 ### Objective diff --git a/data/app.js b/data/app.js index f0cc0ed..9dc6213 100644 --- a/data/app.js +++ b/data/app.js @@ -1,48 +1,77 @@ (() => { - const target = document.querySelector('#health'); - const token = localStorage.getItem('battleship.sessionToken') || ''; + const columns = ['А', 'Б', 'В', 'Г', 'Д', 'Ж', 'З', 'И', 'Й', 'К']; + const storage = { token: 'battleship.sessionToken', name: 'battleship.displayName' }; + const screens = ['connect', 'lobby', 'game', 'result', 'reconnecting', 'error']; + const el = id => document.getElementById(id); + const ui = { + status: el('connection-status'), notice: el('notice'), name: el('display-name'), availability: el('availability'), + lobbyDescription: el('lobby-description'), lobbyHelp: el('lobby-help'), modeControls: el('mode-controls'), + start: el('start-game'), boards: el('boards'), resultBoards: el('result-boards'), tabs: el('board-tabs'), + turn: el('turn-status'), wins: el('wins'), shotControls: el('shot-controls'), target: el('target-status'), + fire: el('fire-button'), cancel: el('cancel-target'), abort: el('abort-game'), rematch: el('rematch-button'), + result: el('result-description'), error: el('error-description') + }; + let token = localStorage.getItem(storage.token) || ''; + let role = ''; + let info; + let state; + let selectedTarget; + let activeBoard = 'own'; let socket; - let lastVersion = 0; - let retryIndex = 0; let retryTimer; let pollTimer; let heartbeatTimer; - const labels = { - uptime_ms: 'Время работы (мс)', wifi_state: 'Wi-Fi', rssi_dbm: 'RSSI (дБм)', - free_heap_bytes: 'Свободная память (байт)', min_free_heap_bytes: 'Мин. свободная память (байт)', - build_version: 'Версия сборки' - }; + let retryIndex = 0; - function render(health) { - target.replaceChildren(); - for (const [key, label] of Object.entries(labels)) { - const term = document.createElement('dt'); - const value = document.createElement('dd'); - term.textContent = label; - value.textContent = health[key] ?? 'недоступно'; - target.append(term, value); - } + function showScreen(name) { + screens.forEach(screen => { el(`screen-${screen}`).hidden = screen !== name; }); } - async function refresh() { + function setConnection(text, status) { + ui.status.textContent = text; + ui.status.className = `connection-status ${status || ''}`; + } + + function notify(message, error = false) { + ui.notice.hidden = !message; + ui.notice.textContent = message || ''; + ui.notice.classList.toggle('error', error); + } + + function apiError(payload, fallback) { + return payload?.message || fallback || 'Сервер временно недоступен.'; + } + + async function request(path, options = {}) { + const response = await fetch(path, { cache: 'no-store', ...options }); + let payload; + try { payload = await response.json(); } catch (_) { throw new Error('Сервер вернул некорректный ответ.'); } + if (!response.ok || payload.ok === false) throw new Error(apiError(payload)); + return payload; + } + + function headers() { return { 'Content-Type': 'application/json' }; } + + async function refreshInfo() { try { - const response = await fetch('/api/health', { cache: 'no-store' }); - if (!response.ok) throw new Error(`HTTP ${response.status}`); - render(await response.json()); + info = await request('/api/info'); + ui.availability.textContent = `Игрок 1: ${info.player1Available ? 'свободен' : 'занят'} · Игрок 2: ${info.player2Available ? 'свободен' : 'занят'} · зрительских мест: ${info.spectatorsAvailable}`; } catch (error) { - target.textContent = `Не удалось получить состояние: ${error.message}`; + ui.availability.textContent = error.message; } } async function pollState() { try { - const response = await fetch(`/api/state?version=${lastVersion}`, { + const response = await fetch(`/api/state?version=${state?.version || 0}`, { cache: 'no-store', headers: token ? { 'X-Session-Token': token } : {} }); - if (!response.ok) throw new Error(`HTTP ${response.status}`); - const state = await response.json(); - acceptState(state); - } catch (_) {} + const payload = await response.json(); + if (!response.ok || payload.ok === false) throw new Error(apiError(payload)); + acceptState(payload); + } catch (error) { + if (!state) showError(error.message); + } } function beginPolling() { @@ -50,54 +79,227 @@ pollState(); } - function stopPolling() { - if (pollTimer) window.clearInterval(pollTimer); - pollTimer = undefined; + function stopPolling() { if (pollTimer) window.clearInterval(pollTimer); pollTimer = undefined; } + function stopHeartbeat() { if (heartbeatTimer) window.clearInterval(heartbeatTimer); heartbeatTimer = undefined; } + + function safeState(payload) { + return payload && payload.type === 'state' && Array.isArray(payload.boards) && payload.boards.length === 2 && + payload.boards.every(board => typeof board === 'string' && /^[0123]{100}$/.test(board)) && + typeof payload.version === 'number' && typeof payload.gameId === 'number'; } - function stopHeartbeat() { - if (heartbeatTimer) window.clearInterval(heartbeatTimer); - heartbeatTimer = undefined; + function acceptState(payload) { + if (!safeState(payload)) { showError('Получено неполное состояние игры.'); return; } + const hadGap = state && payload.version > state.version + 1; + state = payload; + role = payload.viewer; + if (hadGap) pollState(); + selectedTarget = selectedTarget && payload.boards[opponentIndex()][selectedTarget.y * 10 + selectedTarget.x] === '0' ? selectedTarget : undefined; + document.title = `Морской бой — версия ${payload.version}`; + render(); } - function acceptState(state) { - if (lastVersion && state.version > lastVersion + 1) pollState(); - lastVersion = state.version; - document.title = `Морской бой — версия ${state.version}`; + function isPlayer() { return role === 'player1' || role === 'player2'; } + function ownIndex() { return role === 'player2' ? 1 : 0; } + function opponentIndex() { return ownIndex() ^ 1; } + function canShoot() { return state?.phase === 'in_progress' && state.turn === role && isPlayer(); } + function playerLabel(index) { return `Игрок ${index + 1}`; } + + function cellInfo(value) { + if (value === '1') return ['ship', 'Корабль']; + if (value === '2') return ['miss', 'Промах']; + if (value === '3') return ['hit', 'Попадание']; + return ['', 'Вода']; + } + + function boardElement(index, title, targetable, result) { + const board = document.createElement('section'); + board.className = 'board'; + board.dataset.board = String(index); + const heading = document.createElement('h3'); + heading.textContent = title; + board.append(heading); + const grid = document.createElement('div'); + grid.className = 'board-grid'; + grid.setAttribute('role', 'grid'); + grid.setAttribute('aria-label', title); + grid.append(Object.assign(document.createElement('span'), { className: 'axis' })); + columns.forEach(column => grid.append(Object.assign(document.createElement('span'), { className: 'axis', textContent: column }))); + for (let y = 0; y < 10; y += 1) { + grid.append(Object.assign(document.createElement('span'), { className: 'axis', textContent: String(y + 1) })); + for (let x = 0; x < 10; x += 1) { + const value = state.boards[index][y * 10 + x]; + const [kind, label] = cellInfo(value); + const cell = document.createElement('button'); + cell.type = 'button'; cell.className = `cell ${kind}`.trim(); cell.disabled = !targetable || value !== '0'; + cell.setAttribute('aria-label', `${columns[x]}${y + 1}: ${label}`); + if (targetable && value === '0') { + cell.classList.add('target'); + if (selectedTarget?.x === x && selectedTarget?.y === y) cell.classList.add('selected'); + cell.addEventListener('click', () => { selectedTarget = { x, y }; renderGame(); }); + } + grid.append(cell); + } + } + board.append(grid); + if (result) board.hidden = false; + return board; + } + + function boardDefinitions(result = false) { + if (role === 'spectator' || result) return [ + { index: 0, title: 'Поле игрока 1', key: 'player1', targetable: false }, + { index: 1, title: 'Поле игрока 2', key: 'player2', targetable: false } + ]; + return [ + { index: ownIndex(), title: 'Моё поле', key: 'own', targetable: false }, + { index: opponentIndex(), title: 'Поле соперника', key: 'opponent', targetable: canShoot() } + ]; + } + + function renderBoards(container, result = false) { + container.replaceChildren(); + const definitions = boardDefinitions(result); + definitions.forEach(definition => { + const board = boardElement(definition.index, definition.title, definition.targetable, result); + board.hidden = !result && definition.key !== activeBoard; + container.append(board); + }); + if (!result) { + ui.tabs.replaceChildren(); + definitions.forEach(definition => { + const tab = document.createElement('button'); + tab.type = 'button'; tab.role = 'tab'; tab.textContent = definition.title; + tab.setAttribute('aria-selected', String(definition.key === activeBoard)); + tab.addEventListener('click', () => { activeBoard = definition.key; renderGame(); }); + ui.tabs.append(tab); + }); + } + } + + function renderLobby() { + showScreen('lobby'); + const playerOne = role === 'player1'; + ui.lobbyDescription.textContent = role === 'spectator' ? 'Вы наблюдаете за подготовкой партии.' : 'Ожидайте готовности партии или настройте режим.'; + ui.modeControls.hidden = !playerOne; + document.querySelectorAll('.mode-button').forEach(button => { + const selected = button.dataset.mode === state.mode; + button.setAttribute('aria-pressed', String(selected)); + button.disabled = !playerOne; + }); + ui.start.disabled = !playerOne; + ui.lobbyHelp.textContent = playerOne ? (state.mode === 'human' ? 'Для игры вдвоём дождитесь второго игрока.' : 'ESP32 станет вашим соперником.') : 'Игрок 1 выбирает режим и запускает партию.'; + } + + function renderGame() { + if (!state) return; + showScreen('game'); + ui.turn.textContent = state.turn === role ? 'Ваш ход. Выберите клетку соперника.' : `Ходит ${state.turn === 'player1' ? 'игрок 1' : 'игрок 2'}.`; + ui.wins.textContent = `Победы: ${state.wins[0]} : ${state.wins[1]}`; + renderBoards(ui.boards); + const available = canShoot(); + ui.shotControls.hidden = !isPlayer(); + ui.target.textContent = available ? (selectedTarget ? `Цель: ${columns[selectedTarget.x]}${selectedTarget.y + 1}. Подтвердите выстрел.` : 'Выберите клетку на поле соперника.') : 'Ожидайте своего хода.'; + ui.fire.disabled = !available || !selectedTarget; + ui.cancel.disabled = !selectedTarget; + ui.abort.hidden = !(role === 'player1' && state.mode === 'human' && state.phase === 'in_progress'); + } + + function renderResult() { + showScreen('result'); + const waiting = state.phase === 'rematch_wait'; + ui.result.textContent = waiting ? 'Ожидается подтверждение повторной игры.' : 'Поля раскрыты. Можно подтвердить повторную игру.'; + ui.rematch.hidden = !isPlayer(); + ui.rematch.disabled = !isPlayer(); + ui.rematch.textContent = waiting ? 'Подтвердить повторно' : 'Сыграть ещё'; + renderBoards(ui.resultBoards, true); + } + + function render() { + if (!state) return; + if (state.phase === 'lobby' || state.phase === 'preparing') renderLobby(); + else if (state.phase === 'in_progress') renderGame(); + else renderResult(); + } + + function showError(message) { + ui.error.textContent = message; + setConnection('Нет связи', 'offline'); + showScreen('error'); + } + + async function post(path, body) { + try { + const response = await request(path, { method: 'POST', headers: headers(), body: JSON.stringify(body) }); + await pollState(); + return response; + } catch (error) { notify(error.message, true); throw error; } + } + + async function join(requestedRole) { + const name = ui.name.value.trim(); + if (!name) { notify('Введите имя.', true); ui.name.focus(); return; } + try { + const joined = await request('/api/session/join', { method: 'POST', headers: headers(), body: JSON.stringify({ name, requestedRole }) }); + token = joined.token; role = joined.role; + localStorage.setItem(storage.token, token); localStorage.setItem(storage.name, name); + notify('Подключено.'); + await pollState(); + connectSocket(); + } catch (error) { notify(error.message, true); } } function connectSocket() { - if (!token) { beginPolling(); return; } + if (!token || socket?.readyState === WebSocket.OPEN || retryTimer) return; const scheme = location.protocol === 'https:' ? 'wss' : 'ws'; - socket = new WebSocket(`${scheme}://${location.host}/ws`); - socket.onopen = () => { - socket.send(JSON.stringify({ type: 'hello', token, version: lastVersion })); - }; + try { socket = new WebSocket(`${scheme}://${location.host}/ws`); } catch (_) { beginPolling(); return; } + socket.onopen = () => socket.send(JSON.stringify({ type: 'hello', token, version: state?.version || 0 })); socket.onmessage = event => { try { const message = JSON.parse(event.data); - if (message.type !== 'state') return; - acceptState(message); - retryIndex = 0; - stopPolling(); - if (!heartbeatTimer) { - heartbeatTimer = window.setInterval(() => { - if (socket?.readyState === WebSocket.OPEN) socket.send('{"type":"ping"}'); - }, 15000); - } + if (message.type === 'state') { + acceptState(message); retryIndex = 0; stopPolling(); setConnection('Синхронизация онлайн', 'online'); + if (!heartbeatTimer) heartbeatTimer = window.setInterval(() => { if (socket?.readyState === WebSocket.OPEN) socket.send('{"type":"ping"}'); }, 15000); + } else if (message.ok === false) notify(apiError(message), true); } catch (_) { socket.close(); } }; + socket.onerror = () => socket.close(); socket.onclose = () => { - stopHeartbeat(); - beginPolling(); + socket = undefined; stopHeartbeat(); beginPolling(); setConnection('HTTP-обновление', 'offline'); const delays = [1000, 2000, 5000, 10000]; const delay = delays[Math.min(retryIndex++, delays.length - 1)]; - retryTimer = window.setTimeout(connectSocket, delay); + retryTimer = window.setTimeout(() => { retryTimer = undefined; connectSocket(); }, delay); }; - socket.onerror = () => socket.close(); } - refresh(); - window.setInterval(refresh, 5000); - connectSocket(); + el('join-form').addEventListener('submit', event => { + event.preventDefault(); + const requestedRole = info?.player1Available ? 'player1' : info?.player2Available ? 'player2' : 'spectator'; + join(requestedRole); + }); + el('join-spectator').addEventListener('click', () => join('spectator')); + document.querySelectorAll('.mode-button').forEach(button => button.addEventListener('click', () => post('/api/game/config', { token, gameId: state.gameId, mode: button.dataset.mode }))); + ui.start.addEventListener('click', () => post('/api/game/start', { token, gameId: state.gameId })); + ui.fire.addEventListener('click', async () => { if (!selectedTarget) return; await post('/api/game/shot', { token, gameId: state.gameId, ...selectedTarget }); selectedTarget = undefined; }); + ui.cancel.addEventListener('click', () => { selectedTarget = undefined; renderGame(); }); + ui.abort.addEventListener('click', () => post('/api/game/abort', { token, gameId: state.gameId })); + ui.rematch.addEventListener('click', () => post('/api/game/rematch', { token, gameId: state.gameId })); + el('retry-button').addEventListener('click', () => { refreshInfo(); pollState(); connectSocket(); }); + + async function boot() { + ui.name.value = localStorage.getItem(storage.name) || ''; + setConnection('Проверяем ESP32…'); + await refreshInfo(); + if (!token) { showScreen('connect'); setConnection('Готово к подключению'); return; } + try { + const resumed = await request('/api/session/resume', { method: 'POST', headers: headers(), body: JSON.stringify({ token }) }); + role = resumed.role; + await pollState(); + connectSocket(); + } catch (_) { + localStorage.removeItem(storage.token); token = ''; showScreen('connect'); notify('Предыдущая сессия больше недоступна. Подключитесь снова.'); + } + } + + boot(); })(); diff --git a/data/index.html b/data/index.html index edb6ad4..0b7b74a 100644 --- a/data/index.html +++ b/data/index.html @@ -3,17 +3,53 @@ + Морской бой — ESP32 -
-

Морской бой

-

ESP32-C6: проверка Wi-Fi, LittleFS и HTTP.

-
-

Состояние устройства

-
Загрузка…
+
+
+

ESP32-C6 · локальная игра

Морской бой

+

Подключение…

+
+ + +
+

Подключение к игре

+

Введите имя, чтобы занять место игрока или наблюдать за партией.

+
+ + +
+
+

Проверяем доступные места…

+ + + + + + + +
diff --git a/data/styles.css b/data/styles.css index 510c515..a56898d 100644 --- a/data/styles.css +++ b/data/styles.css @@ -1,7 +1,25 @@ -:root { color-scheme: dark; font-family: system-ui, sans-serif; } -body { margin: 0; background: #10223a; color: #f4f7fb; } -main { max-width: 42rem; margin: 0 auto; padding: 1.5rem; } -section { background: #19395c; border-radius: .75rem; padding: 1rem; } -dl { display: grid; grid-template-columns: max-content 1fr; gap: .5rem 1rem; } -dt { font-weight: 700; } -dd { margin: 0; overflow-wrap: anywhere; } +:root { color-scheme: dark; font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; background: #061827; color: #f1f7ff; } +* { box-sizing: border-box; } +body { margin: 0; min-width: 20rem; background: radial-gradient(circle at top, #104c75, #061827 45rem); } +button, input { font: inherit; } +button { min-height: 2.75rem; padding: .55rem .9rem; border: 0; border-radius: .65rem; background: #3ec6f0; color: #032035; font-weight: 750; cursor: pointer; } +button:focus-visible, input:focus-visible, .cell:focus-visible { outline: .2rem solid #ffe56a; outline-offset: .15rem; } +button:hover:not(:disabled) { filter: brightness(1.08); } button:disabled { cursor: not-allowed; opacity: .48; } +button.secondary { background: #31536e; color: #f1f7ff; } button.text-button { min-height: 2rem; padding: .35rem 0; background: transparent; color: #bad9eb; text-decoration: underline; } +.app-shell { width: min(100%, 76rem); margin: 0 auto; padding: clamp(1rem, 3vw, 2rem); } +.app-header, .screen-heading { display: flex; align-items: start; justify-content: space-between; gap: 1rem; } +h1, h2, h3, p { margin-top: 0; } h1 { margin-bottom: .2rem; font-size: clamp(1.75rem, 6vw, 2.6rem); } h2 { font-size: clamp(1.35rem, 4vw, 1.8rem); } h3 { margin-bottom: .75rem; font-size: 1.05rem; } +.eyebrow, .muted { color: #b6d1e3; } .eyebrow { margin-bottom: .25rem; font-size: .8rem; font-weight: 700; letter-spacing: .08em; text-transform: uppercase; } +.connection-status { margin: .25rem 0; padding: .4rem .6rem; border-radius: 99rem; background: #28485f; color: #d8eaff; font-size: .88rem; white-space: nowrap; } .connection-status.online { background: #145b4b; color: #cdfae6; } .connection-status.offline { background: #6f3c36; color: #ffddd8; } +.notice { margin: 1rem 0; padding: .75rem 1rem; border-left: .3rem solid #ffe56a; border-radius: .35rem; background: #403b22; } .notice.error { border-color: #ff8e80; background: #4d2929; } +.screen, .panel { margin-top: 1rem; padding: clamp(1rem, 3vw, 1.5rem); border: 1px solid #41708d; border-radius: 1rem; background: rgb(8 39 62 / 92%); box-shadow: 0 1rem 3rem rgb(0 0 0 / 18%); } .panel { margin-top: 1rem; background: #0b304b; } +.stack-form { display: grid; gap: .7rem; max-width: 28rem; } input { width: 100%; min-height: 2.75rem; padding: .55rem .7rem; border: 1px solid #6a94ad; border-radius: .65rem; background: #061c2d; color: #f1f7ff; } +.button-row { display: flex; flex-wrap: wrap; gap: .6rem; } .button-row > * { flex: 1 1 11rem; } .mode-button[aria-pressed="true"] { background: #ffe56a; color: #312c00; } +.turn-status { margin-bottom: 0; color: #c8e9f8; font-weight: 650; } .score { margin: .15rem 0; padding: .5rem .75rem; border-radius: .6rem; background: #123b58; font-weight: 750; white-space: nowrap; } +.board-tabs { display: flex; gap: .5rem; margin: 1rem 0; } .board-tabs button { flex: 1; min-height: 2.5rem; background: #31536e; color: #f1f7ff; } .board-tabs button[aria-selected="true"] { background: #3ec6f0; color: #032035; } +.boards { display: grid; grid-template-columns: minmax(0, 1fr); gap: 1rem; } .board { min-width: 0; padding: .75rem; border: 1px solid #50809c; border-radius: .8rem; background: #071f32; } .board h3 { margin: 0 0 .65rem; font-size: 1rem; } +.board-grid { display: grid; grid-template-columns: 1.15rem repeat(10, minmax(0, 1fr)); gap: 2px; width: 100%; aspect-ratio: 1.1; } .axis { display: grid; place-items: center; color: #b8d3e6; font-size: clamp(.52rem, 2.2vw, .72rem); font-weight: 700; } +.cell { min-height: 0; padding: 0; border-radius: .12rem; border: 1px solid #4e87a6; background: #167aa6; color: #fff; font-size: clamp(.68rem, 3vw, 1.15rem); line-height: 1; } .cell.ship { background: #b2c8d6; color: #1c3442; } .cell.miss::before { content: "•"; color: #08253a; font-size: 1.15em; } .cell.hit, .cell.sunk { background: #b83e42; color: #fff; } .cell.hit::before, .cell.sunk::before { content: "×"; font-size: 1.25em; font-weight: 900; } .cell.sunk { outline: 2px solid #ffe56a; outline-offset: -3px; } .cell.target { background: #0c648b; cursor: pointer; } .cell.selected { outline: 3px solid #ffe56a; outline-offset: -3px; } .cell[disabled] { opacity: 1; cursor: default; } +.shot-controls { display: flex; flex-wrap: wrap; align-items: center; gap: .7rem; margin-top: 1rem; } .shot-controls p { flex: 1 1 14rem; margin: 0; } .reconnecting { border-color: #ffe56a; } .error-screen { border-color: #ff8e80; } +@media (min-width: 44rem) { .boards { grid-template-columns: repeat(2, minmax(0, 1fr)); } .board-tabs { display: none; } .board[hidden] { display: block; } } +@media (max-width: 43.99rem) { .board[hidden] { display: none; } .result-boards .board[hidden] { display: block; } } diff --git a/platformio.ini b/platformio.ini index e5bf145..a469341 100644 --- a/platformio.ini +++ b/platformio.ini @@ -16,5 +16,6 @@ board_build.partitions = partitions.csv board_build.filesystem = littlefs board_build.sdkconfig_defaults = sdkconfig.defaults lib_deps = https://github.com/joltwallet/esp_littlefs.git#v1.20.4 +extra_scripts = pre:scripts/compress_web_assets.py monitor_port = /dev/ttyACM0 monitor_speed = 115200 diff --git a/scripts/__pycache__/compress_web_assets.cpython-313.pyc b/scripts/__pycache__/compress_web_assets.cpython-313.pyc new file mode 100644 index 0000000..033a6e6 Binary files /dev/null and b/scripts/__pycache__/compress_web_assets.cpython-313.pyc differ diff --git a/scripts/compress_web_assets.py b/scripts/compress_web_assets.py new file mode 100644 index 0000000..549aa86 --- /dev/null +++ b/scripts/compress_web_assets.py @@ -0,0 +1,66 @@ +Import("env") + +import gzip +import re +from pathlib import Path + + +ROOT = Path(env.subst("$PROJECT_DIR")) +DATA = ROOT / "data" +ASSETS = ("index.html", "styles.css", "app.js") + + +def minify_html(source): + return re.sub(r">\s+<", "><", source).strip() + + +def minify_css(source): + source = re.sub(r"/\*.*?\*/", "", source, flags=re.S) + return re.sub(r"\s*([{}:;,>])\s*", r"\1", source).strip() + + +def minify_js(source): + output = [] + quote = "" + escaped = False + pending_space = False + for character in source: + if quote: + output.append(character) + if escaped: + escaped = False + elif character == "\\": + escaped = True + elif character == quote: + quote = "" + continue + if character in ("'", '"', "`"): + if pending_space and output and (output[-1].isalnum() or output[-1] in "_$"): + output.append(" ") + pending_space = False + quote = character + output.append(character) + elif character.isspace(): + pending_space = True + else: + if pending_space and output and (output[-1].isalnum() or output[-1] in "_$") and (character.isalnum() or character in "_$"): + output.append(" ") + pending_space = False + output.append(character) + return "".join(output) + + +def compress_assets(): + for asset in ASSETS: + path = DATA / asset + text = path.read_text(encoding="utf-8") + if asset.endswith(".html"): + text = minify_html(text) + elif asset.endswith(".css"): + text = minify_css(text) + else: + text = minify_js(text) + (DATA / f"{asset}.gz").write_bytes(gzip.compress(text.encode("utf-8"), mtime=0)) + + +compress_assets()