feat(ui): consolidate secondary controls in settings menu

This commit is contained in:
2026-09-13 22:16:11 +03:00
parent 8a37412416
commit 44433aee9c
9 changed files with 388 additions and 31 deletions
+289 -1
View File
@@ -2960,7 +2960,7 @@ without automatically opening the optional player help overlay.
## Milestone 027 — Playful breakthrough movement and prominent hedge gaps
**Status:** `IN_PROGRESS`
**Status:** `COMPLETE`
### Objective
@@ -3126,3 +3126,291 @@ passage on supported mobile and desktop screens.
2,031,616 / 2,031,616 byte LittleFS image.
- Mobile, desktop, observer, reduced-motion, and physical-device visual
acceptance remain required before this milestone can be marked `COMPLETE`.
## Milestone 028 — Compact settings menu
**Status:** `COMPLETE`
### Objective
Reduce persistent browser-interface clutter on every supported viewport by
consolidating secondary connection and player-preference controls into one
compact settings menu opened by a gear button beside the Wi-Fi connection
indicator.
### Scope
- Do not start implementation until every preceding milestone is `COMPLETE`.
Milestone 027 is `COMPLETE` when this plan is created; this milestone remains
`NOT_STARTED` because implementation was not requested.
- Keep the Wi-Fi connection indicator visible and place one persistent
gear-icon menu button next to it on welcome, lobby, active-player, observer,
pause, and result screens where the corresponding secondary actions are
available.
- Remove the separate persistent buttons for connection details, sound,
reduced motion, and help. Expose those actions as clearly labelled Russian
items in the gear-button popover.
- Move the Wi-Fi settings action out of the connection-details panel and into
the same popover. Do not duplicate the Wi-Fi settings action elsewhere in
the normal connected application flow.
- Preserve the existing behavior and current state of connection details,
sound, reduced motion, the optional help overlay, and Wi-Fi settings. The
sound and reduced-motion items must visibly and accessibly communicate their
current on/off state.
- Keep primary gameplay actions outside the settings menu, including movement,
avatar drag, key drop, lobby participation, match start, observer navigation,
and result actions.
- Make the popover usable with touch, pointer, and keyboard. Give its trigger
an accessible Russian name and exposed open/closed state; provide predictable
focus order; close it after an applicable navigation action, on outside
activation, and with Escape; and restore focus to the trigger when
appropriate.
- Prevent the popover from covering essential controls or escaping the
viewport. Anchor it to the top-right status area and allow it to reposition
or constrain its height on narrow portrait, compact landscape, desktop, and
safe-area-inset layouts.
- Preserve Russian player-facing copy, offline operation, observer read-only
behavior, server-authoritative game state, supported browser layouts, and
firmware and LittleFS budgets.
### Deliverables
- Updated compact-settings-menu behavior and responsive-layout contracts in
`docs/UI_STYLE.md` and any affected supporting UI documentation.
- One reusable gear-triggered settings popover with connection details, sound,
reduced motion, help, and Wi-Fi settings actions shown only when applicable.
- Removal of the superseded standalone secondary-control buttons and of the
duplicated Wi-Fi settings entry from the connection-details panel.
- Deterministic browser tests for visibility, state reflection, action routing,
keyboard and pointer dismissal, focus behavior, responsive placement, and
absence of duplicate controls.
- Browser, host-regression, compressed-web-asset, LittleFS, and representative
mobile and desktop visual-verification evidence.
### Acceptance criteria
1. Every supported screen and viewport shows at most one persistent secondary
settings control: a gear button beside the visible Wi-Fi connection
indicator. The previous standalone connection-details, sound,
reduced-motion, and help buttons are absent.
2. Opening the gear button displays the applicable connection details, sound,
reduced motion, help, and Wi-Fi settings actions with clear Russian labels.
No primary gameplay or lobby action is moved into the menu.
3. Connection details show the same live information as before, and their
former standalone round button is not rendered.
4. Sound and reduced-motion actions retain their existing behavior and reflect
their current on/off state visually and through accessible state. Changing
either setting updates the menu item without creating another persistent
control.
5. Help opens the existing optional help overlay without changing its content,
navigation, close behavior, or the direct countdown-to-maze transition.
6. Wi-Fi settings remain reachable from the menu through the existing
configuration flow, and the action is not duplicated in the
connection-details panel or elsewhere in the normal connected application
flow.
7. The trigger and popover work with touch, pointer, and keyboard. The trigger
has an accessible Russian name and accurate expanded state; focus order is
predictable; Escape and outside activation dismiss the popover; and focus
returns safely without trapping the player.
8. Opening, using, and closing the popover does not issue gameplay commands,
change authoritative game state, enable observer input, or interfere with
keyboard, swipe, directional-pad, avatar-drag, key-drop, pause, or result
controls.
9. At all supported mobile, compact-landscape, tablet, and desktop sizes, the
closed layout is more compact than the current standalone-control layout.
The open popover stays within safe areas and the viewport, remains readable,
introduces no page or map overflow, and does not permanently cover
essential controls.
10. Automated tests cover screen applicability, absence of standalone and
duplicate controls, state reflection, every routed action, dismissal,
focus restoration, supported responsive layouts, and regression behavior.
11. All host and browser tests pass; compressed-web-asset validation passes;
the payload remains within the 1.5 MiB web budget and LittleFS partition;
and firmware remains within the factory partition.
12. Physical-device verification confirms that the menu and all five
secondary actions work in the served application, and the project owner
confirms on representative mobile and desktop browsers that the interface
is less distracting and materially more compact.
### Evidence
- On 2026-09-13, planning-time inspection of the active game at
`http://192.168.1.50/` identified the separate round connection-details
button beside `ESP на связи` and the standalone sound, reduced-motion, and
help controls in the active-player layout.
- On 2026-09-13, the project owner chose a gear icon beside the connection
indicator, required all referenced secondary controls and Wi-Fi settings to
move into its popover without duplicating the Wi-Fi action, and required the
compact layout on all screen sizes.
- On 2026-09-13, implementation began with one header gear button, a bounded
safe-area-aware settings popover, and the removal of the superseded details,
sound, reduced-motion, and help buttons. The popover retains live connection
information and Wi-Fi navigation on every screen, while player preferences
and optional help appear only during an active player match.
- On 2026-09-13, `node --test test/web/test_player_app.js
test/web/test_hedge_svg.js` passed all 31 tests. The compact-settings test
covers the sole trigger, removed standalone controls, state labels,
player-screen applicability, Escape and outside dismissal, and safe-area
constrained placement.
- On 2026-09-13, `bash scripts/run_host_tests.sh` passed all host and bundled
browser checks. `python3 scripts/verify_web_assets.py` passed with a
deterministic 31,420 / 1,572,864 byte compressed payload, and
`/home/sasa/.platformio/penv/bin/pio run --target buildfs` succeeded with a
2,031,616 / 2,031,616 byte LittleFS image.
- Mobile, desktop, and physical-device visual verification of all settings
actions were pending until the project-owner check below.
- On 2026-09-13, the project owner checked the UI in every available mode and
confirmed that the settings menu is present and works as expected. This
supplies the required visual and physical-device acceptance evidence.
## Milestone 029 — Low-overhead live connection quality
**Status:** `NOT_STARTED`
### Objective
Show a compact, continuously understandable indication of the browser's live
round-trip response to the ESP on every application screen, with an honest
breakdown between measured ESP handling time and the remaining communication
delay, without reducing gameplay responsiveness or consuming unbounded device
resources.
### Scope
- Do not start implementation until every preceding milestone is `COMPLETE`.
Milestone 028 is `COMPLETE` when this plan is created; this milestone remains
`NOT_STARTED` because implementation was not requested.
- Reuse the existing versioned WebSocket heartbeat request and acknowledgement
path. Correlate each probe with its unique request ID and use the browser's
monotonic clock to measure complete application round-trip time.
- Measure heartbeat handling duration only with the ESP's monotonic clock,
from bounded request handling to acknowledgement preparation, and return
that duration in the matching acknowledgement. Never subtract an ESP
timestamp from a browser timestamp or require clock synchronization.
- Derive the communication remainder as the non-negative difference between
browser-measured round-trip time and ESP-measured handling time. Label it
`Связь`, not pure `Wi-Fi`, because it can also include TCP/WebSocket
transport, task scheduling, and outbound queueing. Label the measured device
interval `ESP` and the full round trip `Отклик`.
- Keep one compact persistent quality indicator beside the existing connection
status and settings trigger. Show a connection-quality symbol and rounded
total milliseconds without expanding the header or displacing primary
content.
- Make the `Связь` and `ESP` values available in the existing connection
details reached through the settings menu and in the indicator's accessible
Russian description. Do not expose developer diagnostics or misleading
claims about which person or device is at fault.
- Show the indicator on welcome, lobby, active-player, observer, countdown,
pause, and result screens. Before the first valid sample, while a sample is
stale, and while disconnected, show explicit compact unknown or offline
states instead of retaining an old number as if it were current.
- Define documented quality thresholds from representative physical-device
measurements. Use stable good, delayed, and slow states with smoothing and
hysteresis so normal jitter does not make the indicator flicker. State must
not rely on color alone.
- Keep probing strictly bounded and lower priority than gameplay: at most one
heartbeat may be outstanding per browser, normal cadence may not exceed one
probe every five seconds, no retry burst is allowed, and a probe or its
response must be skipped or dropped rather than delay a move, projection,
pause, resume, or result message.
- Suspend probes while the WebSocket is closed and while the document is
hidden. Clear pending measurements on disconnect, session reset, and socket
replacement, then take one fresh sample after a successful connection before
resuming the bounded cadence.
- Keep a small fixed-size sample window in the browser only. Do not persist or
transmit latency history, player names, session tokens, network names, or
hidden game state as part of this feature.
- Preserve observer read-only behavior, server-authoritative gameplay,
existing connection-loss messaging, offline operation, Russian
player-facing copy, responsive layouts, and firmware and LittleFS budgets.
### Deliverables
- Updated measurement, protocol, quality-state, copy, and responsive-placement
contracts in `ARCHITECTURE.md`, `docs/UI_STYLE.md`, `docs/PLAYER_UI.md`, and
the Milestone 016 latency procedure where applicable.
- A bounded heartbeat round-trip sampler and ESP handling-duration field with
documented units, range validation, correlation, timeout, and stale-sample
behavior.
- A compact always-applicable connection-quality indicator showing total
milliseconds, plus `Связь` and `ESP` details integrated with the existing
settings and connection-details interface.
- Deterministic protocol, gateway, browser, accessibility, responsive-layout,
scheduling, smoothing, timeout, reconnect, and backpressure tests.
- Before-and-after maximum-session latency, queue, traffic, heap, web-asset,
firmware, LittleFS, and physical-device visual-verification evidence.
### Acceptance criteria
1. On every supported application screen, the header contains one compact
quality symbol and a rounded current total such as `85 мс` beside the
existing connection status and gear trigger, without moving or obscuring
primary game, lobby, observer, pause, or result controls.
2. Each displayed total is calculated from the browser monotonic send and
matching-heartbeat-acknowledgement times. Responses with a missing,
duplicate, expired, malformed, or mismatched request ID cannot update the
indicator.
3. The matching heartbeat acknowledgement carries a bounded ESP handling
duration measured solely with the ESP monotonic clock. The browser validates
the field and derives `Связь = max(0, Отклик - ESP)` without comparing clocks
from different devices.
4. Connection details show the same sample as three Russian-labelled values:
`Отклик`, `Связь`, and `ESP`. Supporting text explains that `Связь` is a
transport remainder and cannot isolate radio-only Wi-Fi delay or assign
blame with certainty.
5. Before the first sample, after the documented stale timeout, and while
disconnected, the indicator uses distinct unknown, stale, or offline text
and shape states. It never presents an old millisecond value as current.
6. Good, delayed, and slow thresholds, smoothing window, hysteresis, rounding,
sample timeout, and stale timeout are fixed in canonical documentation and
covered at every boundary by deterministic tests. Meaning remains clear in
grayscale and without relying on animation or color alone.
7. Each browser has at most one heartbeat in flight, sends no more than one
normal probe per five seconds, keeps only the documented fixed number of
recent samples, and produces no immediate retry loop after timeout,
malformed input, disconnect, background suspension, or reconnect.
8. Heartbeat work is constant and bounded on the ESP, causes no broadcast or
authoritative-state mutation, and cannot occupy queue capacity needed by
gameplay or lifecycle messages. Under backpressure its acknowledgement is
discarded before any move, view, pause, resume, or result message.
9. Pointer feedback, keyboard, swipe, directional-pad, avatar-drag, key-drop,
rendering, and observer navigation remain responsive while sampling. The
Milestone 017 controlled two-player and two-player/four-observer latency
budgets continue to pass, with no increased dropped gameplay messages or
queue high-water regression attributable to probes.
10. Visibility changes, socket replacement, disconnect, reconnection, session
reset, screen changes, and result transitions leave no orphan timer,
duplicate probe, stale accessible label, or retained pending correlation.
11. The indicator and its details remain readable and within the viewport on
supported narrow portrait, compact-landscape, tablet, and desktop layouts,
including safe-area insets and all player and observer states.
12. Automated protocol, gateway, browser, host, and web-asset tests pass. The
compressed payload remains within the 1.5 MiB web budget and LittleFS
partition, and firmware remains within the factory partition.
13. Physical-device testing with two players and four observers confirms the
displayed total and split track controlled fast and delayed conditions,
reconnect states recover correctly, and continuous sampling causes no
perceptible input delay, animation stutter, disconnect, or gameplay
regression.
14. The project owner confirms on representative mobile and desktop browsers
that the indicator is compact, understandable in every application state,
and useful for recognizing a delayed ESP response without distracting from
play, or explicitly approves a documented evidence deviation.
### Evidence
- On 2026-09-13, planning inspection confirmed that the protocol already
supports a correlated `heartbeat` acknowledgement and that the browser uses
monotonic timing with a bounded 256-sample diagnostic tracker for accepted
movement acknowledgements and painted projections.
- On 2026-09-13, the project owner requested a compact icon with a millisecond
value in every application state and asked to separate communication delay
from ESP processing when this can be done without complicating or slowing
gameplay.
- Planning analysis established that a single round trip cannot measure pure
radio-only Wi-Fi latency. This milestone therefore specifies a directly
measured ESP handling interval and an honestly labelled communication
remainder, while keeping the displayed headline value equal to the complete
browser-to-ESP application round trip.
- Implementation and verification have not started, as requested by the
project owner.
Binary file not shown.
+10
View File
@@ -56,6 +56,16 @@ a maze boundary, and is recalculated on resize and rotation.
## Feedback and message rail
## Compact settings
The persistent gear button beside the connection indicator opens one compact
popover. It shows live connection details and Wi-Fi settings on every screen.
During an active player match it additionally provides sound, reduced-motion,
and optional-help controls with visible Russian on/off labels. Opening or
closing this presentation-only menu never sends a command or changes server
state. Escape and outside activation dismiss it; player actions also close it
before opening their existing destination.
The browser stores the direction for each outstanding move request. A matching
server `blocked` rejection may include only that player's approved breakthrough
stage, which bends the already rendered bush wall for at most 340 ms without
+6 -2
View File
@@ -100,8 +100,12 @@ are not MVP requirements.
- The player identity is represented by a distinct character shape, not `P1`
or `P2`. Object, blocked-move, pause, and result feedback must pair a shape
or icon with its color.
- Connection details and the Wi-Fi settings link belong in a secondary panel,
not in the persistent status row.
- A single gear button beside the persistent Wi-Fi connection indicator opens
the secondary settings popover. It contains connection details and Wi-Fi
settings on every screen, plus the player-only sound, reduced-motion, and
help actions during an active player match. The popover is constrained to
safe areas, closes on outside activation or Escape, and never contains
primary game, lobby, observer, or result actions.
- Countdown, pause, and result states appear as modal overlays above the map.
- Textual system notices use one live message rail above the top bar and player
status area. It shows one message at a time: connection loss has highest
+25 -3
View File
@@ -140,7 +140,7 @@ test("countdown completion leaves help closed until the player requests it", ()
assert.match(activeView, /setDirectionalPadEnabled\(snapshot\.phase === "active"/);
assert.match(activeView, /else hideModal\(\);/);
assert.doesNotMatch(activeView, /showOnboarding\(0\)/);
assert.match(script, /byId\("onboarding-replay"\)\.addEventListener\("click", \(\) => showOnboarding\(0\)\)/);
assert.match(script, /byId\("settings-help"\)\.addEventListener\("click", \(\) => \{ closeSettings\(false\); showOnboarding\(0\); \}\)/);
assert.match(script, /byId\("onboarding-skip"\)\.addEventListener\("click", hideOnboarding\)/);
assert.match(script, /if \(next == null\) hideOnboarding\(\)/);
});
@@ -162,7 +162,7 @@ test("avatar control resets safely and retains accessible alternatives", () => {
assert.match(script, /setReducedMotion\(!state\.reducedMotion\)/);
assert.match(html, /id="avatar-control"/);
assert.match(html, /aria-label="Тянуть героя для движения"/);
assert.match(script, /onboarding-replay/);
assert.match(script, /settings-help/);
assert.match(html, /id="onboarding-dots"[\s\S]*?<span><\/span><span><\/span><span><\/span><span><\/span><span><\/span><span><\/span><span><\/span><span><\/span>/);
});
@@ -359,7 +359,7 @@ test("player assets are local, Russian, accessible, and responsive", () => {
assert.match(css, /\.dpad \{[^}]*grid-template-columns: repeat\(3, 58px\)/);
assert.match(html, /id="avatar-control"/);
assert.match(html, /id="onboarding"/);
assert.match(html, /id="mute-toggle"/);
assert.match(html, /id="settings-mute"/);
assert.match(css, /\.avatar-control \{[^}]*width: 96px; height: 96px/);
assert.match(css, /\.avatar-handle \{[^}]*transition: transform/);
assert.match(css, /\.avatar-handle \{[^}]*background: transparent/);
@@ -370,6 +370,28 @@ test("player assets are local, Russian, accessible, and responsive", () => {
assert.match(icons, /id="maze"/);
});
test("one compact settings popover replaces standalone secondary controls", () => {
const html = fs.readFileSync(path.join(project, "web/index.html"), "utf8");
const css = fs.readFileSync(path.join(project, "web/player.css"), "utf8");
const script = fs.readFileSync(path.join(project, "web/player.js"), "utf8");
assert.match(html, /id="settings-toggle"[^>]*aria-controls="settings-menu"[^>]*aria-expanded="false"[^>]*aria-label="Открыть настройки"/);
for (const identifier of ["settings-menu", "details-text", "settings-mute", "settings-motion", "settings-help", "settings-wifi"]) {
assert.match(html, new RegExp(`id="${identifier}"`));
}
assert.doesNotMatch(html, /id="details-toggle"|id="details-panel"|id="mute-toggle"|id="motion-toggle"|id="onboarding-replay"/);
assert.doesNotMatch(html, /access-controls|access-button/);
assert.match(css, /\.settings-menu \{[^}]*max-height: calc\(100dvh/);
assert.match(css, /\.settings-item\[aria-pressed="true"\]/);
assert.match(script, /function openSettings\(\)/);
assert.match(script, /function closeSettings\(restoreFocus\)/);
assert.match(script, /event\.key === "Escape" && !byId\("settings-menu"\)\.hidden/);
assert.match(script, /!menu\.hidden && !menu\.contains\(event\.target\) && !trigger\.contains\(event\.target\)/);
assert.match(script, /\["settings-mute", "settings-motion", "settings-help"\]\.forEach\(id => \{ byId\(id\)\.hidden = name !== "match"; \}\)/);
assert.match(script, /button\.textContent = muted \? "Звук: выкл" : "Звук: вкл"/);
assert.match(script, /button\.textContent = reduced \? "Движение: выкл" : "Движение: вкл"/);
});
test("reference-derived SVG gameplay symbols preserve local yellow and blue ownership", () => {
const icons = fs.readFileSync(path.join(project, "web/icons.svg"), "utf8");
const script = fs.readFileSync(path.join(project, "web/player.js"), "utf8");
+1
View File
@@ -9,6 +9,7 @@
<symbol id="eye" viewBox="0 0 24 24"><path d="M2 12s4-7 10-7 10 7 10 7-4 7-10 7S2 12 2 12Z" fill="none" stroke="currentColor" stroke-width="2"/><circle cx="12" cy="12" r="3" fill="currentColor"/></symbol>
<symbol id="arrow" viewBox="0 0 24 24"><path d="M12 2l7 9h-4v11H9V11H5l7-9Z" fill="currentColor"/></symbol>
<symbol id="signal" viewBox="0 0 24 24"><path d="M3 9a14 14 0 0 1 18 0M6 13a9 9 0 0 1 12 0m-9 4a4 4 0 0 1 6 0" fill="none" stroke="currentColor" stroke-width="2.5"/><circle cx="12" cy="21" r="1.5" fill="currentColor"/></symbol>
<symbol id="gear" viewBox="0 0 24 24"><path d="M10 2h4l.7 3 2.6 1.1 2.7-1.5 2 3.5-2.2 2 .1 3 2.1 1.9-2 3.5-2.7-1.5-2.6 1.1-.7 3h-4l-.7-3-2.6-1.1-2.7 1.5-2-3.5 2.1-1.9.1-3-2.2-2 2-3.5 2.7 1.5L9.3 5Z" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linejoin="round"/><circle cx="12" cy="12" r="3" fill="none" stroke="currentColor" stroke-width="1.8"/></symbol>
<symbol id="radar" viewBox="0 0 24 24"><circle cx="12" cy="12" r="9" fill="none" stroke="currentColor" stroke-width="2"/><circle cx="12" cy="12" r="4" fill="none" stroke="currentColor" stroke-width="2"/><path d="M12 12 20 6" stroke="currentColor" stroke-width="2"/><circle cx="12" cy="12" r="2" fill="currentColor"/></symbol>
<symbol id="hourglass" viewBox="0 0 24 24"><path d="M6 3h12M6 21h12M7 4c0 5 4 5 5 8-1 3-5 3-5 8m10-16c0 5-4 5-5 8 1 3 5 3 5 8" fill="none" stroke="currentColor" stroke-width="2"/></symbol>
<symbol id="sound" viewBox="0 0 24 24"><path d="M4 10h4l5-4v12l-5-4H4zM17 9c2 2 2 4 0 6M20 6c4 4 4 8 0 12" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"/></symbol>

Before

Width:  |  Height:  |  Size: 4.4 KiB

After

Width:  |  Height:  |  Size: 4.8 KiB

+9 -7
View File
@@ -13,9 +13,16 @@
<header class="topbar">
<a class="brand" href="/" aria-label="maze32, на главную">MAZE<span>32</span></a>
<div class="connection-pill" id="connection" data-state="connecting" role="status"><span class="signal" aria-hidden="true"></span><span id="connection-text">Ловим сигнал ESP…</span></div>
<button class="icon-button" id="details-toggle" type="button" aria-controls="details-panel" aria-expanded="false"><svg aria-hidden="true"><use href="/icons.svg#signal"></use></svg><span class="sr-only">Открыть сведения о соединении</span></button>
<button class="icon-button" id="settings-toggle" type="button" aria-controls="settings-menu" aria-expanded="false" aria-label="Открыть настройки"><svg aria-hidden="true"><use href="/icons.svg#gear"></use></svg></button>
</header>
<aside class="details-panel" id="details-panel" hidden><strong>Связь с коробочкой</strong><span id="details-text">Подключение ещё не готово.</span><a href="/provision">Настроить Wi‑Fi</a></aside>
<aside class="settings-menu" id="settings-menu" hidden aria-label="Настройки">
<strong>Настройки</strong>
<span class="settings-details" id="details-text">Подключение ещё не готово.</span>
<button class="settings-item" id="settings-mute" type="button" aria-pressed="false" hidden>Звук: вкл</button>
<button class="settings-item" id="settings-motion" type="button" aria-pressed="false" hidden>Движение: вкл</button>
<button class="settings-item" id="settings-help" type="button" hidden>Показать обучение</button>
<a class="settings-item" id="settings-wifi" href="/provision">Настроить Wi‑Fi</a>
</aside>
<section class="screen welcome-screen" id="welcome-screen" aria-labelledby="welcome-title">
<div class="hero-art" aria-hidden="true"><span class="pixel-bush bush-one"></span><span class="pixel-bush bush-two"></span><svg><use href="/icons.svg#maze"></use></svg></div>
@@ -59,11 +66,6 @@
<div class="child-controls" aria-label="Игровые действия">
<button class="drop-key" id="drop-key" type="button" aria-label="Сбросить ключ" disabled><svg aria-hidden="true"><use href="/icons.svg#key"></use></svg><strong>OK</strong><span>бросить</span></button>
</div>
<div class="access-controls" aria-label="Настройки игры">
<button class="access-button" id="mute-toggle" type="button" aria-label="Выключить звук"><svg aria-hidden="true"><use href="/icons.svg#sound"></use></svg></button>
<button class="access-button" id="motion-toggle" type="button" aria-label="Уменьшить движение"><svg aria-hidden="true"><use href="/icons.svg#motion"></use></svg></button>
<button class="access-button" id="onboarding-replay" type="button" aria-label="Показать обучение"><svg aria-hidden="true"><use href="/icons.svg#help"></use></svg></button>
</div>
<nav class="dpad" aria-label="Управление движением"><button type="button" data-direction="north" aria-label="Двигаться вверх" disabled>▲</button><button type="button" data-direction="west" aria-label="Двигаться влево" disabled>◀</button><span class="dpad-center" aria-hidden="true">●</span><button type="button" data-direction="east" aria-label="Двигаться вправо" disabled>▶</button><button type="button" data-direction="south" aria-label="Двигаться вниз" disabled>▼</button></nav>
<section class="onboarding" id="onboarding" hidden aria-label="Обучение игре">
<div class="onboarding-card" id="onboarding-card"><svg class="onboarding-icon" aria-hidden="true"><use id="onboarding-icon-use" href="/icons.svg#hand"></use></svg><h2 id="onboarding-title">Потяни кружок</h2><p id="onboarding-text">Кружок ведёт героя по лабиринту.</p><div class="onboarding-dots" id="onboarding-dots" aria-hidden="true"><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span></div><button id="onboarding-next" type="button" aria-label="Следующий шаг обучения">Дальше</button><button id="onboarding-skip" type="button" aria-label="Пропустить обучение">Пропустить</button></div>
+6 -8
View File
@@ -41,9 +41,11 @@ button { color: inherit; }
.connection-pill[data-state="offline"] .signal { background: var(--disconnect); box-shadow: 0 0 10px var(--disconnect); }
.icon-button { width: 38px; height: 38px; padding: 8px; border: 1px solid var(--edge); background: var(--panel-deep); cursor: pointer; }
.icon-button svg { width: 100%; height: 100%; color: var(--aqua); }
.details-panel { position: absolute; z-index: 30; top: 60px; right: max(12px, env(safe-area-inset-right)); display: grid; gap: 6px; width: min(300px, calc(100% - 24px)); padding: 14px; border: 2px solid var(--edge); background: #120b21f5; box-shadow: 6px 6px 0 var(--shadow); font-size: .8rem; }
.details-panel span { color: var(--muted); }
.details-panel a { color: var(--aqua); }
.settings-menu { position: fixed; z-index: 40; top: max(58px, calc(env(safe-area-inset-top) + 58px)); right: max(12px, env(safe-area-inset-right)); display: grid; gap: 8px; width: min(300px, calc(100% - 24px)); max-height: calc(100dvh - max(70px, env(safe-area-inset-top)) - max(12px, env(safe-area-inset-bottom))); overflow-y: auto; padding: 14px; border: 2px solid var(--edge); background: #120b21f5; box-shadow: 6px 6px 0 var(--shadow); font-size: .8rem; }
.settings-details { color: var(--muted); line-height: 1.45; }
.settings-item { min-height: 42px; display: flex; align-items: center; justify-content: space-between; border: 1px solid var(--edge); background: #0c0815; padding: 8px 10px; color: var(--ink); font: inherit; text-align: left; text-decoration: none; cursor: pointer; }
.settings-item:hover, .settings-item:focus-visible { border-color: var(--aqua); background: #164049; }
.settings-item[aria-pressed="true"] { border-color: var(--warning); color: var(--warning); }
.screen { min-height: calc(100dvh - 90px); animation: screen-in .28s steps(5, end); }
.welcome-screen { display: grid; grid-template-columns: minmax(260px, 1fr) minmax(320px, 500px); align-items: center; gap: clamp(24px, 6vw, 90px); padding: 5vh 4vw; }
@@ -183,10 +185,6 @@ input:focus { border-color: var(--aqua); box-shadow: 0 0 0 3px #43ead229; }
.avatar-control[data-speed="slow"] .avatar-handle { box-shadow: 4px 4px 0 #000, 0 0 0 3px #43ead270; }
.child-controls { position: absolute; z-index: 12; inset: auto 0 12px; display: flex; align-items: end; justify-content: space-between; padding: 0 16px; pointer-events: none; }
.child-controls > * { pointer-events: auto; }
.access-controls { position: absolute; z-index: 14; top: 92px; right: 8px; display: grid; gap: 6px; }
.access-button { width: 42px; height: 42px; display: grid; place-items: center; border: 2px solid var(--aqua); border-radius: 50%; background: #102a30e8; color: var(--aqua); box-shadow: 3px 3px 0 #020506; }
.access-button svg { width: 24px; height: 24px; }
.access-button[aria-pressed="true"] { color: var(--warning); border-color: var(--warning); }
.onboarding { position: absolute; z-index: 30; inset: 0; display: grid; place-items: center; padding: 18px; background: #08050bd9; }
.onboarding-card { width: min(340px, 92%); padding: 20px; border: 3px solid var(--aqua); background: var(--panel-deep); box-shadow: 7px 7px 0 #020103; text-align: center; }
.onboarding-icon { width: 104px; height: 104px; color: var(--aqua); animation: guide-hand 1.2s ease-in-out infinite; }
@@ -288,7 +286,7 @@ input:focus { border-color: var(--aqua); box-shadow: 0 0 0 3px #43ead229; }
.map-shell { inset: 82px 0 0; }
.child-controls { bottom: max(14px, env(safe-area-inset-bottom)); padding: 0 10px; }
.drop-key { position: relative; top: auto; right: auto; bottom: auto; width: 78px; height: 78px; }
.access-controls { top: 88px; right: 8px; }
.settings-menu { top: max(52px, calc(env(safe-area-inset-top) + 52px)); }
.dpad { right: max(12px, env(safe-area-inset-right)); bottom: max(12px, env(safe-area-inset-bottom)); grid-template-columns: repeat(3, 62px); grid-template-rows: repeat(3, 62px); gap: 5px; }
.dpad button { min-width: 62px; min-height: 62px; font-size: 1.3rem; }
.connection-pill { font-size: .58rem; }
+42 -10
View File
@@ -422,7 +422,26 @@
byId("match-screen").hidden = name !== "match";
byId("observer-screen").hidden = name !== "observer";
documentObject.body.classList.toggle("match-active", name === "match");
["settings-mute", "settings-motion", "settings-help"].forEach(id => { byId(id).hidden = name !== "match"; });
if (name !== "match") stopBreakthroughTravel();
if (name !== "match") closeSettings(false);
}
function closeSettings(restoreFocus) {
const menu = byId("settings-menu");
if (menu.hidden) return;
menu.hidden = true;
const trigger = byId("settings-toggle");
trigger.setAttribute("aria-expanded", "false");
if (restoreFocus) trigger.focus();
}
function openSettings() {
const menu = byId("settings-menu");
menu.hidden = false;
byId("settings-toggle").setAttribute("aria-expanded", "true");
const firstAction = menu.querySelector(".settings-item:not([hidden])");
if (firstAction) firstAction.focus();
}
function renderWelcomeHeadline(headline) {
@@ -951,23 +970,27 @@
function setMuted(muted) {
state.muted = muted;
const button = byId("mute-toggle");
const button = byId("settings-mute");
button.setAttribute("aria-pressed", String(muted));
button.setAttribute("aria-label", muted ? "Включить звук" : "Выключить звук");
button.title = muted ? "Включить звук" : "Выключить звук";
button.querySelector("use").setAttribute("href", muted ? "/icons.svg#sound" : "/icons.svg#sound");
button.textContent = muted ? "Звук: выкл" : "Звук: вкл";
}
function setReducedMotion(reduced) {
state.reducedMotion = reduced;
if (reduced) stopBreakthroughTravel();
documentObject.documentElement.dataset.reducedMotion = String(reduced);
byId("motion-toggle").setAttribute("aria-pressed", String(reduced));
byId("motion-toggle").setAttribute("aria-label", reduced ? "Включить движение" : "Уменьшить движение");
const button = byId("settings-motion");
button.setAttribute("aria-pressed", String(reduced));
button.textContent = reduced ? "Движение: выкл" : "Движение: вкл";
}
documentObject.addEventListener("pointerdown", beginFeedback, {once: true});
documentObject.addEventListener("keydown", event => {
if (event.key === "Escape" && !byId("settings-menu").hidden) {
event.preventDefault();
closeSettings(true);
return;
}
if (event.target instanceof windowObject.HTMLInputElement || byId("match-screen").hidden) return;
beginFeedback();
const direction = directionFromKey(event.code); if (direction) { event.preventDefault(); move(direction, null); }
@@ -1025,9 +1048,14 @@
});
});
byId("drop-key").addEventListener("click", () => send("drop_key", {}, byId("drop-key")));
byId("mute-toggle").addEventListener("click", () => setMuted(!state.muted));
byId("motion-toggle").addEventListener("click", () => setReducedMotion(!state.reducedMotion));
byId("onboarding-replay").addEventListener("click", () => showOnboarding(0));
byId("settings-toggle").addEventListener("click", () => {
if (byId("settings-menu").hidden) openSettings();
else closeSettings(false);
});
byId("settings-mute").addEventListener("click", () => setMuted(!state.muted));
byId("settings-motion").addEventListener("click", () => setReducedMotion(!state.reducedMotion));
byId("settings-help").addEventListener("click", () => { closeSettings(false); showOnboarding(0); });
byId("settings-wifi").addEventListener("click", () => closeSettings(false));
byId("onboarding-next").addEventListener("click", () => {
const next = nextOnboardingStep(state.onboardingStep);
if (next == null) hideOnboarding();
@@ -1052,7 +1080,11 @@
const stopObserverDrag = event => { if (state.observer.dragging && state.observer.dragging.id === event.pointerId) { state.observer.dragging = null; byId("observer-stage").classList.remove("dragging"); } };
byId("observer-stage").addEventListener("pointerup", stopObserverDrag);
byId("observer-stage").addEventListener("pointercancel", stopObserverDrag);
byId("details-toggle").addEventListener("click", () => { const panel = byId("details-panel"); panel.hidden = !panel.hidden; byId("details-toggle").setAttribute("aria-expanded", String(!panel.hidden)); });
documentObject.addEventListener("pointerdown", event => {
const menu = byId("settings-menu");
const trigger = byId("settings-toggle");
if (!menu.hidden && !menu.contains(event.target) && !trigger.contains(event.target)) closeSettings(false);
});
byId("modal-action").addEventListener("click", () => {
if (state.session && state.session.role === "player" && state.session.slot === 1 && state.snapshot && state.snapshot.phase === "finished") {
send("restart", {}, byId("modal-action"));