1 Commits
Author SHA1 Message Date
Commander1024 f40c09c11a Refine web quick panels and Wi-Fi controls 2026-09-14 00:05:27 +02:00
6 changed files with 207 additions and 39 deletions
+6
View File
@@ -2,6 +2,12 @@
Working memory, not an implementation timeline. Source is authoritative; begin with [code map](code-map.md), then [architecture](architecture.md) and [decisions](design-decisions.md).
## Web popup cosmetics — 2026-09-14
- `src/web_ui.c`: contextual buttons now occupy entire status cards; bounded grid columns and clipped/ellipsized values prevent narrow-window overflow. Quick panels preserve the visible terminal title, admin toolbar and selected-view indication; full-page promotion retains drafts and restores Settings presentation.
- Quick panels hide full-page explanations and duplicate detail readouts while preserving live status, errors and operation results. Wi-Fi exposes configured profile selection/enabled state and AP policy, plus Apply/Save/Refresh/Result; empty profile slots and advanced editors remain full-page-only. Profile selection is an editing target, not a direct-connect command; existing backend operations are unchanged.
- Validation: all 161 UI behavior groups and C/HTML checks PASS; 36 Chromium geometry fixtures PASS at 320/600/900/1200px. `pio run` PASS (94,212 B linked RAM / 1,830,329 B flash). No generated-asset regeneration, upload, erase or hardware validation. Updated regression coverage in `tests/web_ui_session/`.
## Focused cleanup / PSRAM review — 2026-09-13
- User-authorized post-acceptance code review: removed superseded `admin_ssh_console_open()` SSH-only wrapper; production already uses available-slot admission. Updated adapter regression to actual production entry. Wrapper was already linker-discarded: no binary saving attributed to removal.
+35 -27
View File
@@ -26,9 +26,13 @@ static const char s_index_html[] =
"--good:#52d68b;--warn:#ffc857;--bad:#ff6b7a;--radius:14px}\n"
"*{box-sizing:border-box}\n"
"[hidden]{display:none!important}\n"
".quick-trigger{background:none;border:0;padding:0;color:inherit;text-align:left;cursor:pointer}.quick-trigger:focus-visible{outline:2px solid var(--accent);outline-offset:3px}\n"
".quick-trigger{display:block;width:100%;color:inherit;text-align:left;font:inherit;cursor:pointer;overflow:hidden}.quick-trigger:disabled{cursor:default}.quick-trigger:focus-visible{outline:2px solid var(--accent);outline-offset:3px}\n"
".settings-page[data-quick='true']{position:fixed;z-index:10;top:8px;right:8px;width:min(600px,calc(100vw - 16px));max-height:calc(100vh - 16px);max-height:calc(100dvh - 16px);overflow:auto;background:var(--panel);border:1px solid var(--line);border-radius:var(--radius);padding:16px;box-shadow:0 8px 32px #0008}\n"
"[data-quick='true']>#settings-navigation,[data-quick='true'] #network-password-mode-label,[data-quick='true'] #network-password-label,[data-quick='true'] #network-password-status{display:none}\n"
"[data-quick='true'] .full-settings-only,[data-quick='true'] #settings-navigation,[data-quick='true'] #quick-help,[data-quick='true'] h3,[data-quick='true']>div>h2,[data-quick='true'] p:not([role=status]),[data-quick='true'] #network-summary,[data-quick='true'] #broker-values,[data-quick='true'] #network-ssid-detail{display:none}\n"
"[data-quick='true'] #quick-header>h2{display:block}[data-quick='true'] #settings-values>:nth-child(n+3){display:none}\n"
"[data-quick='true'] #network-edit>*{display:none}[data-quick='true'] #network-edit>.settings-edit:first-of-type{display:grid}[data-quick='true'] #network-edit>.network-quick-actions{display:flex}\n"
"[data-quick='true'] #network-edit>.settings-edit>label{display:none}[data-quick='true'] #network-edit>.settings-edit>#network-target-label,[data-quick='true'] #network-edit>.settings-edit>#network-policy-label{display:grid}[data-quick='true'] #network-edit>.settings-edit>#network-enabled-label{display:flex}\n"
"[data-quick='true'] #network-wifi-load{display:none}\n"
"html,body{height:100%;margin:0;overflow:hidden}\n"
"body{background:radial-gradient(circle at top left,#142033 0,var(--bg) 42rem);"
"color:var(--text);font:14px/1.45 system-ui,-apple-system,BlinkMacSystemFont,"
@@ -55,7 +59,7 @@ static const char s_index_html[] =
".dashboard{display:grid;grid-template-columns:minmax(0,1.35fr) minmax(280px,.65fr);gap:16px}\n"
".panel{background:linear-gradient(145deg,#151e2cdd,#0f1621ee);border:1px solid var(--line);"
"border-radius:var(--radius);box-shadow:0 16px 45px #0005}\n"
".status-grid{padding:16px;display:grid;grid-template-columns:repeat(4,minmax(105px,1fr));gap:12px}\n"
".status-grid{min-width:0;padding:16px;display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:12px}\n"
".status-item{min-width:0;padding:10px 12px;background:#0b111b99;border:1px solid #202c3e;"
"border-radius:10px}\n"
".status-item.wide{grid-column:span 2}\n"
@@ -152,14 +156,10 @@ static const char s_index_html[] =
"<span id=\"role-status\" class=\"badge\" data-tone=\"warn\">Observer</span></div>\n"
"<div class=\"status-item\"><span class=\"label\">Broker client</span>"
"<span id=\"client-id\" class=\"value\">—</span></div>\n"
"<div class=\"status-item\"><span class=\"label\">Active writer</span>"
"<button type=\"button\" id=\"quick-writer\" class=\"quick-trigger\" aria-label=\"Active writer management\" aria-controls=\"serial-settings\" aria-expanded=\"false\" aria-haspopup=\"dialog\" disabled><span id=\"writer-id\" class=\"value\">None</span></button></div>\n"
"<div class=\"status-item\"><span class=\"label\">Broker clients</span>"
"<button type=\"button\" id=\"quick-broker\" class=\"quick-trigger\" aria-label=\"Live broker clients\" aria-controls=\"serial-settings\" aria-expanded=\"false\" aria-haspopup=\"dialog\" disabled><span id=\"broker-clients\" class=\"value\"></span></button></div>\n"
"<div class=\"status-item wide\"><span class=\"label\">Wi-Fi</span>"
"<button type=\"button\" id=\"quick-network\" class=\"quick-trigger\" aria-label=\"Wi-Fi quick settings\" aria-controls=\"serial-settings\" aria-expanded=\"false\" aria-haspopup=\"dialog\" disabled><span id=\"wifi-summary\" class=\"value\">Loading…</span></button></div>\n"
"<div class=\"status-item wide\"><span class=\"label\">Serial</span>"
"<button type=\"button\" id=\"quick-serial\" class=\"quick-trigger\" aria-label=\"Serial quick settings\" aria-controls=\"serial-settings\" aria-expanded=\"false\" aria-haspopup=\"dialog\" disabled><span id=\"serial-summary\" class=\"value\">Loading…</span></button></div>\n"
"<button type=\"button\" id=\"quick-writer\" class=\"status-item quick-trigger\" aria-label=\"Active writer management\" aria-controls=\"serial-settings\" aria-expanded=\"false\" aria-haspopup=\"dialog\" disabled><span class=\"label\">Active writer</span><span id=\"writer-id\" class=\"value\">None</span></button>\n"
"<button type=\"button\" id=\"quick-broker\" class=\"status-item quick-trigger\" aria-label=\"Live broker clients\" aria-controls=\"serial-settings\" aria-expanded=\"false\" aria-haspopup=\"dialog\" disabled><span class=\"label\">Broker clients</span><span id=\"broker-clients\" class=\"value\"></span></button>\n"
"<button type=\"button\" id=\"quick-network\" class=\"status-item wide quick-trigger\" aria-label=\"Wi-Fi quick settings\" aria-controls=\"serial-settings\" aria-expanded=\"false\" aria-haspopup=\"dialog\" disabled><span class=\"label\">Wi-Fi</span><span id=\"wifi-summary\" class=\"value\">Loading…</span></button>\n"
"<button type=\"button\" id=\"quick-serial\" class=\"status-item wide quick-trigger\" aria-label=\"Serial quick settings\" aria-controls=\"serial-settings\" aria-expanded=\"false\" aria-haspopup=\"dialog\" disabled><span class=\"label\">Serial</span><span id=\"serial-summary\" class=\"value\">Loading…</span></button>\n"
"</div>\n"
"<div class=\"panel controls\">\n"
"<div class=\"button-row\">\n"
@@ -201,7 +201,7 @@ static const char s_index_html[] =
"<div class=\"serial-actions\"><button id=\"lifecycle-stop\" class=\"button\" type=\"button\">Stop HTTPS…</button><button id=\"lifecycle-restart\" class=\"button\" type=\"button\">Restart HTTPS…</button><button id=\"lifecycle-reboot\" class=\"button\" type=\"button\">Reboot device…</button><button id=\"lifecycle-rotate\" class=\"button\" type=\"button\">Rotate HTTPS identity…</button><button id=\"lifecycle-result\" class=\"button\" type=\"button\">Check Operation Result</button></div>"
"<p id=\"lifecycle-operation-detail\" class=\"connection-detail\" role=\"status\">Explicit confirmation required. Acknowledgement is not peer receipt or completion. Connection loss, expiry, revocation or timeout does not prove cancellation after admission. No automatic mutation retry or restore. Check Result, inspect state, then act explicitly.</p><a href=\"/\">Reload / sign in after recovery</a></div>\n"
"<div id=\"ssh-settings\" hidden><h2>SSH service and sessions</h2><p class=\"connection-detail\">SSH only, TCP port 22. Start/Stop do not change saved settings or host identity. Stop closes all SSH sessions, including any admitted before execution; an SSH administrator's already executing command may finish. HTTPS login, browser terminals, Wi-Fi, USB and UART0 are not stopped. Targeted disconnect affects only the selected SSH connection, not all logins for its account. Viewing or selecting never changes services or writer ownership.</p><button id=\"ssh-refresh\" class=\"button\" type=\"button\">Refresh</button><p id=\"ssh-detail\" class=\"connection-detail\" role=\"status\"></p><dl id=\"ssh-values\" class=\"settings-values\"></dl><div class=\"settings-edit\"><label>Disconnect SSH session<select id=\"ssh-target\"><option value=\"\">Select a session</option><option id=\"ssh-option-0\" hidden disabled></option><option id=\"ssh-option-1\" hidden disabled></option></select></label></div><div class=\"serial-actions\"><button id=\"ssh-start\" class=\"button\" type=\"button\">Start SSH…</button><button id=\"ssh-stop\" class=\"button\" type=\"button\">Stop SSH…</button><button id=\"ssh-rotate\" class=\"button\" type=\"button\">Rotate SSH host identity…</button><button id=\"ssh-disconnect\" class=\"button\" type=\"button\">Disconnect selected…</button><button id=\"ssh-result\" class=\"button\" type=\"button\">Check Operation Result</button></div><p id=\"ssh-operation-detail\" class=\"connection-detail\" role=\"status\">Explicit confirmation required. After submission use Check Operation Result, then Refresh. Navigation or timeout does not cancel admitted work. No automatic mutation retry.</p></div>\n"
"<div id=\"broker-settings\" hidden><h2>Broker clients and writer</h2><p class=\"connection-detail\">One writer, multiple isolated observers. Viewing, refreshing and selecting do not change the lease or either terminal. Assignment revokes the previous writer, without recalling bytes already accepted by UART. Any intervening lease transition rejects stale confirmation, even release and reacquire by the same writer.</p><p class=\"connection-detail\">Pending and high-water are bounded output bytes; dropped counts cover this connection or the last shell counter clear. No UART data is consumed. Refresh retains explicit selection without renewing its lease token. Stale selections require choosing the blank option then the target again. No persistence or disconnect controls.</p><button id=\"broker-refresh\" class=\"button\" type=\"button\">Refresh</button><p id=\"broker-detail\" class=\"connection-detail\" role=\"status\"></p><dl id=\"broker-values\" class=\"settings-values\"></dl><div class=\"settings-edit\"><label>Assign writer to<select id=\"broker-target\"><option value=\"\">Select a connected client</option><option id=\"broker-option-0\" hidden disabled></option><option id=\"broker-option-1\" hidden disabled></option><option id=\"broker-option-2\" hidden disabled></option><option id=\"broker-option-3\" hidden disabled></option><option id=\"broker-option-4\" hidden disabled></option><option id=\"broker-option-5\" hidden disabled></option><option id=\"broker-option-6\" hidden disabled></option><option id=\"broker-option-7\" hidden disabled></option></select></label></div><div class=\"serial-actions\"><button id=\"broker-assign\" class=\"button\" type=\"button\">Assign writer…</button><button id=\"broker-result\" class=\"button\" type=\"button\">Check Operation Result</button></div><p id=\"broker-operation-detail\" class=\"connection-detail\" role=\"status\">Explicit confirmation required. Navigation or timeout does not cancel admitted work. Check Result after uncertainty; no automatic mutation retry.</p></div>\n"
"<div id=\"broker-settings\" hidden><h2>Broker clients and writer</h2><p class=\"connection-detail\">One writer, multiple isolated observers. Viewing, refreshing and selecting do not change the lease or either terminal. Assignment revokes the previous writer, without recalling bytes already accepted by UART. Any intervening lease transition rejects stale confirmation, even release and reacquire by the same writer.</p><p class=\"connection-detail\">Pending and high-water are bounded output bytes; dropped counts cover this connection or the last shell counter clear. No UART data is consumed. Refresh retains explicit selection without renewing its lease token. Stale selections require choosing the blank option then the target again. No persistence or disconnect controls.</p><button id=\"broker-refresh\" class=\"button\" type=\"button\">Refresh</button><p id=\"broker-detail\" class=\"connection-detail\" role=\"status\"></p><dl id=\"broker-values\" class=\"settings-values\"></dl><div class=\"settings-edit\"><label>Assign writer to<select id=\"broker-target\"><option value=\"\">Select a connected client</option><option id=\"broker-option-0\" hidden disabled></option><option id=\"broker-option-1\" hidden disabled></option><option id=\"broker-option-2\" hidden disabled></option><option id=\"broker-option-3\" hidden disabled></option><option id=\"broker-option-4\" hidden disabled></option><option id=\"broker-option-5\" hidden disabled></option><option id=\"broker-option-6\" hidden disabled></option><option id=\"broker-option-7\" hidden disabled></option></select></label></div><div class=\"serial-actions\"><button id=\"broker-assign\" class=\"button\" type=\"button\">Assign writer…</button><button id=\"broker-result\" class=\"button\" type=\"button\">Check Operation Result</button></div><p id=\"broker-operation-detail\" class=\"connection-detail\" role=\"status\"><span class=\"full-settings-only\">Explicit confirmation required. Navigation or timeout does not cancel admitted work. Check Result after uncertainty; no automatic mutation retry.</span></p></div>\n"
"<div id=\"display-settings\" hidden><h2>Display</h2>\n"
"<p class=\"connection-detail\">Working OLED inactivity settings, not saved NVS values. Zero disables a transition. Each timeout is 086400 seconds; when both are enabled, Off must be later than Dim.</p>\n"
"<p class=\"connection-detail\">Apply and Defaults change RAM only. Save persists the working snapshot, not browser drafts. Load discards drafts and uses stored settings, or defaults if storage is absent/incompatible; it does not change NVS. Reset saves defaults and applies them. Refresh discards drafts. Intervening configuration edits reject stale operations: Refresh and review before retrying.</p>\n"
@@ -227,9 +227,9 @@ static const char s_index_html[] =
"<button id=\"network-refresh\" class=\"button\" type=\"button\">Refresh</button>"
"<p id=\"network-detail\" class=\"connection-detail\" role=\"status\"></p><dl id=\"network-summary\" class=\"settings-values\"></dl>"
"<div id=\"network-edit\" hidden><h3>Wi-Fi working configuration</h3><div class=\"settings-edit\">"
"<label>Target<select id=\"network-target\"><option value=\"ap\">Access point / boot policy</option>"
"<option value=\"0\">STA profile 0</option><option value=\"1\">STA profile 1</option>"
"<option value=\"2\">STA profile 2</option><option value=\"3\">STA profile 3</option></select></label>"
"<label id=\"network-target-label\">Profile / AP mode<select id=\"network-target\"><option value=\"ap\">Access point</option>"
"<option id=\"network-profile-0\" value=\"0\">STA profile 0</option><option id=\"network-profile-1\" value=\"1\">STA profile 1</option>"
"<option id=\"network-profile-2\" value=\"2\">STA profile 2</option><option id=\"network-profile-3\" value=\"3\">STA profile 3</option></select></label>"
"<label id=\"network-boot-label\" class=\"settings-check\">Enabled at boot<input id=\"network-boot\" type=\"checkbox\"></label>"
"<label id=\"network-policy-label\">AP policy<select id=\"network-policy\"><option>off</option><option>fallback</option><option>always</option></select></label>"
"<label id=\"network-channel-label\">AP channel<input id=\"network-channel\" type=\"number\" min=\"1\" max=\"11\" step=\"1\"></label>"
@@ -246,7 +246,7 @@ static const char s_index_html[] =
"Switching modes is lossless or refused. Password replacement requires 863 printable ASCII characters; blank never clears. "
"AP always requires a PSK. STA clear requires disabled state. Transient passwords expire after 60 seconds and clear on submission or context change. "
"NVS is unencrypted; replacement/clear is not secure flash erasure. JavaScript cannot securely zero strings.</p>"
"<div class=\"serial-actions\"><button id=\"network-apply\" class=\"button\" type=\"button\">Apply selected Wi-Fi target to RAM</button>"
"<div class=\"serial-actions network-quick-actions\"><button id=\"network-apply\" class=\"button\" type=\"button\">Apply selected Wi-Fi target to RAM</button>"
"<button id=\"network-wifi-save\" class=\"button\" type=\"button\">Save working Wi-Fi to NVS</button>"
"<button id=\"network-wifi-load\" class=\"button\" type=\"button\">Load stored Wi-Fi</button>"
"</div><h3>Wi-Fi connection</h3><div class=\"serial-actions\"><button id=\"network-start\" class=\"button\" type=\"button\">Start</button><button id=\"network-stop\" class=\"button\" type=\"button\">Stop</button>"
@@ -261,7 +261,7 @@ static const char s_index_html[] =
"<button id=\"network-mdns-load\" class=\"button\" type=\"button\">Load mDNS</button>"
"<button id=\"network-mdns-defaults\" class=\"button\" type=\"button\">mDNS defaults in RAM</button></div></div>"
"<button id=\"network-result\" class=\"button\" type=\"button\">Check Operation Result</button>"
"<p id=\"network-operation-detail\" class=\"connection-detail\" role=\"status\">After uncertainty, Check Result and Refresh. Never assume timeout or navigation cancels work; no automatic mutation retry.</p></div>"
"<p id=\"network-operation-detail\" class=\"connection-detail\" role=\"status\"><span class=\"full-settings-only\">After uncertainty, Check Result and Refresh. Never assume timeout or navigation cancels work; no automatic mutation retry.</span></p></div>"
"<div id=\"serial-settings-content\"><h2>Serial</h2><p class=\"connection-detail\">Working UART1 configuration below is not a saved NVS snapshot. "
"Navigation leaves both terminals connected and preserves the serial writer lease.</p>"
"<button id=\"refresh-settings\" class=\"button\" type=\"button\">Refresh</button>"
@@ -296,7 +296,7 @@ static const char s_index_html[] =
"<button id=\"serial-start\" class=\"button\" type=\"button\">Start</button>"
"<button id=\"serial-stop\" class=\"button\" type=\"button\">Stop</button></div></div>"
"<button id=\"serial-result\" class=\"button\" type=\"button\">Check Operation Result</button>"
"<p id=\"serial-operation-detail\" class=\"connection-detail\" role=\"status\">Check Result after any uncertain submission; never assume timeout or navigation cancels an operation.</p></div>"
"<p id=\"serial-operation-detail\" class=\"connection-detail\" role=\"status\"><span class=\"full-settings-only\">Check Result after any uncertain submission; never assume timeout or navigation cancels an operation.</span></p></div>"
"<div id=\"account-settings\" hidden><h2>Accounts</h2>"
"<p class=\"connection-detail\">Role and deletion changes are saved immediately and revoke only the affected account's network sessions. The final administrator is protected. "
"Self changes can immediately close all this account's web/SSH sessions, including browser serial/admin. A disconnect or 401 is NOT proof of success; re-login and inspect before retrying.</p>"
@@ -439,7 +439,7 @@ static const char s_app_js[] =
" element('quick-title').textContent = quickTitle(domain);\n"
" element('quick-help').textContent = brokerContext ? 'Live clients share one writer and isolated observation. Opening, refreshing and selecting never transfers the lease. Assign writer requires separate confirmation. Closing does not cancel admitted work; after uncertainty use Check Result, never automatic retry.' : 'Opening and selecting never applies changes. Apply changes RAM; Save persists working device values, not drafts. Closing discards drafts; admitted work is not cancelled. Password changes are available only on the full Network page.';\n"
" element('quick-header').hidden = false; trigger.setAttribute('aria-expanded', 'true');\n"
" terminalHost.hidden = previous !== 'serial'; adminHost.hidden = previous !== 'admin';\n"
" renderTerminalView(previous);\n"
" refreshSettings(); if (activate) element('quick-close').focus();\n"
"}\n"
"function quickContains(target) { return !!quick && !!target && (settingsHost.contains(target) || quick.trigger.contains(target)); }\n"
@@ -458,7 +458,7 @@ static const char s_app_js[] =
"settingsHost.addEventListener('pointerenter', () => window.clearTimeout(quickLeaveTimer));\n"
"settingsHost.addEventListener('pointerleave', quickLeave); settingsHost.addEventListener('focusout', quickLeave);\n"
"element('quick-close').addEventListener('click', () => closeQuick(true));\n"
"element('quick-full').addEventListener('click', event => { event.preventDefault(); if (!quick) return; resetQuick(); clearNetworkSecret(); networkButtons(); terminalHost.hidden = adminHost.hidden = true; element('settings-' + settingsDomain).focus(); });\n"
"element('quick-full').addEventListener('click', event => { event.preventDefault(); if (!quick) return; resetQuick(); clearNetworkSecret(); networkButtons(); renderTerminalView('settings'); element('settings-' + settingsDomain).focus(); });\n"
"window.addEventListener('pointerdown', event => { if (quick && !quickContains(event.target)) closeQuick(); });\n"
"window.addEventListener('keydown', event => { if (quick && event.key === 'Escape') { event.preventDefault(); event.stopPropagation(); closeQuick(true); } });\n"
"function clearSettings() {\n"
@@ -498,7 +498,7 @@ static const char s_app_js[] =
" !['inactive', 'active', 'on-connect'].includes(value.dtr) || !Number.isInteger(value.rts_threshold) || value.rts_threshold < 1 || value.rts_threshold > 127) throw new Error('Invalid snapshot');\n"
" for (const key of settingsFields) element('setting-' + key).textContent = key === 'running' ? (value[key] ? 'Running' : 'Stopped') : String(value[key]);\n"
" for (const key of settingsFields.slice(1)) element('edit-' + key).value = String(value[key]);\n"
" element('serial-edit').hidden = false; element('settings-values').hidden = false; settingsDetail.textContent = (serialOperationPending ? 'Snapshot may be stale: operation outcome pending or unknown. ' : 'Working snapshot loaded. ') + 'Apply changes RAM; Save explicitly persists working settings. Refresh replaces your draft.';\n"
" element('serial-edit').hidden = false; element('settings-values').hidden = false; settingsDetail.textContent = (serialOperationPending ? 'Snapshot may be stale: operation outcome pending or unknown. ' : 'Working snapshot loaded. ') + (quick ? '' : 'Apply changes RAM; Save explicitly persists working settings. Refresh replaces your draft.');\n"
" } catch (error) {\n"
" if (live(generation) && current()) settingsDetail.textContent = (error.status ? error.message : 'Serial snapshot could not be read or was invalid.') + ' Snapshot stale or unavailable. Select Refresh to retry.';\n"
" } finally {\n"
@@ -817,7 +817,7 @@ static const char s_app_js[] =
" }\n"
" }\n"
" element('broker-target').value = brokerSelection && !brokerSelection.missing && brokerClients.some(c => c.id === brokerSelection.target) ? String(brokerSelection.target) : '';\n"
" brokerDetail.textContent = (brokerOperationPending ? 'Outcome pending or unknown. ' : '') + 'Writer: ' + (brokerWriter || 'None') + '. ' + brokerClients.length + ' connected clients. ' + (brokerSelection?.stale ? 'Selection stale: lease or client changed. Choose the blank option then explicitly select again before confirmation. ' : 'Refresh preserves selection, never renews its confirmation token. ') + (quick ? 'Live refresh every 5 seconds after completion. ' : '') + (brokerGeneration === 4294967295 ? 'Confirmation generation exhausted; use the admin shell.' : '');\n"
" brokerDetail.textContent = (brokerOperationPending ? 'Outcome pending or unknown. ' : '') + 'Writer: ' + (brokerWriter || 'None') + '. ' + brokerClients.length + ' connected clients. ' + (brokerSelection?.stale ? 'Selection stale: choose the blank option, then reselect a client. ' : quick ? '' : 'Refresh preserves selection, never renews its confirmation token. ') + (brokerGeneration === 4294967295 ? 'Confirmation generation exhausted; use the admin shell.' : '');\n"
" } catch (error) {\n"
" if (live(generation) && current()) { brokerGeneration = 0; if (brokerSelection) brokerSelection.stale = true; brokerDetail.textContent = (error.status ? error.message : 'Broker snapshot unavailable or invalid.') + ' Selection stale. Select Refresh and reselect to retry.'; }\n"
" } finally { window.clearTimeout(deadline); if (current()) { brokerAbort = null; brokerButtons(); if (brokerGeneration) scheduleBrokerLive(); } }\n"
@@ -1283,6 +1283,11 @@ static const char s_app_js[] =
" net('refresh').disabled = net('result').disabled = busy;\n"
" net('password-clear').hidden = net('password-clear').disabled = net('target').value === 'ap';\n"
" if (quick) { clearNetworkSecret(); net('password-mode').disabled = true; }\n"
" for (let i = 0; i < 4; ++i) {\n"
" const option = net('profile-' + i), profile = networkSnapshot?.wifi.profiles[i];\n"
" option.textContent = 'STA ' + i + (profile?.ssid ? ' — ' + networkSSIDSummary(profile.ssid) : ' (empty)');\n"
" option.hidden = option.disabled = !!quick && !profile?.ssid;\n"
" }\n"
"}\n"
"function clearNetwork() {\n"
" clearNetworkSecret();\n"
@@ -1357,9 +1362,9 @@ static const char s_app_js[] =
" ['AP running', r.ap_running], ['AP clients', r.ap_clients], ['Wi-Fi last error', r.last_error],\n"
" ['mDNS generation', m.generation], ['Hostname', m.hostname + '.local'], ['Expected announcement', m.announced],\n"
" ['mDNS last error', m.last_error], ['DNS verification', 'Not client-verified DNS.']]);\n"
" if (!['ap','0','1','2','3'].includes(net('target').value)) net('target').value = 'ap';\n"
" if (!['ap','0','1','2','3'].includes(net('target').value) || quick && net('target').value !== 'ap' && !w.profiles[Number(net('target').value)]?.ssid) net('target').value = 'ap';\n"
" renderNetworkTarget(); net('suffix').value = m.suffix; net('edit').hidden = false;\n"
" net('detail').textContent = (networkPending ? 'Snapshot may be stale: outcome pending or unknown. ' : 'Working snapshot refreshed (Wi-Fi and mDNS are separate consistent copies). ') + 'Browser drafts are not saved; Save persists device working state.';\n"
" net('detail').textContent = (networkPending ? 'Snapshot may be stale: outcome pending or unknown. ' : '') + (quick ? r.state + ' · IP: ' + (r.ip || 'none') + ' · AP: ' + (r.ap_running ? 'running' : 'off') + ' · Profile: ' + (r.active_profile < 0 ? 'none' : r.active_profile) : 'Working snapshot refreshed (Wi-Fi and mDNS are separate consistent copies). Browser drafts are not saved; Save persists device working state.');\n"
" } catch (error) { if (live(generation) && current()) net('detail').textContent = 'Network snapshot stale or unavailable/invalid. Refresh explicitly to retry. No values inferred.'; }\n"
" finally { if (current()) { networkAbort = null; networkButtons(); } }\n"
"}\n"
@@ -1555,15 +1560,18 @@ static const char s_app_js[] =
" for (let offset = 0; offset < bytes.length; offset += 512) adminSocket.send(bytes.subarray(offset, offset + 512));\n"
" });\n"
" }\n"
" renderTerminalView(mode);\n"
" settingsHost.hidden = mode !== 'settings';\n"
" lastFitWidth = lastFitHeight = 0; updateControls(); scheduleFit();\n"
" if (mode === 'settings') { if (read) refreshSettings(); } else if (focus) (mode === 'serial' ? terminal : adminTerminal).focus();\n"
"}\n"
"function renderTerminalView(mode) {\n"
" terminalHost.hidden = mode !== 'serial'; adminHost.hidden = mode !== 'admin';\n"
" adminToggle.hidden = adminDetail.hidden = mode !== 'admin';\n"
" element('select-serial').setAttribute('aria-pressed', String(mode === 'serial'));\n"
" element('select-admin').setAttribute('aria-pressed', String(mode === 'admin'));\n"
" element('select-settings').setAttribute('aria-pressed', String(mode === 'settings'));\n"
" settingsHost.hidden = mode !== 'settings';\n"
" element('terminal-title').textContent = mode === 'serial' ? 'Live serial stream' : mode === 'admin' ? 'Administration shell' : 'Settings';\n"
" lastFitWidth = lastFitHeight = 0; updateControls(); scheduleFit();\n"
" if (mode === 'settings') { if (read) refreshSettings(); } else if (focus) (mode === 'serial' ? terminal : adminTerminal).focus();\n"
"}\n"
"async function openAdmin() {\n"
" if (accountRole !== 'admin' || selected !== 'admin' || unloading || navigating || loggingOut || suspended || !csrf || adminSocket || adminAbort) return;\n"
+1 -1
View File
@@ -17,7 +17,7 @@ and inline asset-failure script, not a separately maintained implementation.
Historical 8D.17 run: **126 behavior groups**, plus production renderer/HTML/CSP checks. Seven new quick groups cover mouse/focus/touch activation, single DOM ownership, focused-editor hover stability, Escape/outside dismissal, late reads/expiry, terminal input/drain isolation, full-page draft/promotion preservation, explicit Apply/Save and pending non-replay, Network secret exclusion/strict schema/401. `Element` focus/containment/hover and events are doubles, not browser event-dispatch proof.
Optional geometry: `WEB_UI_CHROMIUM=/usr/bin/chromium-browser python3 tests/web_ui_session/run.py`. The fixture covers 18 cases (four full views plus Serial/Wi-Fi quick panels at320/600/1200px, quick height360), including viewport bounds/scrolling and secret-control exclusion. The 8D.17 attempt was **blocked before measurement** by sandbox socket/Crash Reports restrictions; no geometry pass claimed. See [contextual-control contracts](../../docs/web_administration.md#broker-and-contextual-controls), [acceptance evidence and limits](../../docs/web_administration_acceptance.md), and [regression procedures](../../docs/user_administration_tests.md#integrated-web-administration-regression-procedure).
Optional geometry: `WEB_UI_CHROMIUM=/usr/bin/chromium-browser python3 tests/web_ui_session/run.py`. The fixture covers 36 cases at 320/600/900/1200px: full settings and quick panels plus dashboard card hit-testing, overflow/ellipsis, compact controls, status visibility, and full-page restoration. Quick panels use height 360px. The 2026-09-14 cosmetic update passed these Chromium fixtures and all 161 browser behavior groups; fixtures are not live-device validation. See [contextual-control contracts](../../docs/web_administration.md#broker-and-contextual-controls), [acceptance evidence and limits](../../docs/web_administration_acceptance.md), and [regression procedures](../../docs/user_administration_tests.md#integrated-web-administration-regression-procedure).
Coverage:
+26
View File
@@ -115,6 +115,32 @@ async function test(name, fn) { await fn(); ++passed; console.log('PASS JS:', na
trigger.pointerenter({pointerType:'mouse'}); await tick(); assert.equal(host['data-quick'],'false');
b.terminals[0].input('x'); assert.equal(ws.sent.length, 1); assert.ok(!ws.closed);
});
await test('Every quick card retains Serial/Admin title, toolbar and selected-view semantics until full promotion', async () => {
for (const mode of ['serial', 'admin']) for (const domain of ['serial', 'network', 'broker', 'writer']) {
const b = browser({role:'admin'}); b.start(); await tick();
if (mode === 'admin') { b.click('select-admin'); await tick(); }
const assertView = view => {
assert.equal(b.nodes['terminal-title'].textContent, {serial:'Live serial stream', admin:'Administration shell', settings:'Settings'}[view], domain);
for (const id of ['admin-toggle', 'admin-detail']) assert.equal(b.nodes[id].hidden, view !== 'admin', id);
for (const choice of ['serial', 'admin', 'settings']) assert.equal(b.nodes['select-' + choice]['aria-pressed'], String(choice === view), choice);
assert.equal(b.nodes.terminal.hidden, view !== 'serial');
assert.equal(b.nodes['admin-terminal'].hidden, view !== 'admin');
};
b.click('quick-' + domain); await tick(); assertView(mode);
assert.equal(b.nodes['serial-settings'].hidden, false);
assert.equal(b.nodes['serial-settings']['data-quick'], 'true');
b.click('quick-close'); await tick(); assertView(mode);
b.click('quick-' + domain); await tick();
const calls = b.calls.length;
b.nodes['quick-full'].click({preventDefault(){}}); await tick();
assertView('settings');
assert.equal(b.nodes['serial-settings']['data-quick'], 'false');
assert.equal(b.nodes['quick-header'].hidden, true);
assert.equal(b.nodes['quick-' + domain]['aria-expanded'], 'false');
assert.equal(b.document.activeElement, b.nodes['settings-' + (domain === 'writer' ? 'broker' : domain)]);
assert.equal(b.calls.length, calls, 'promotion must not reload the shared controller');
}
});
await test('Quick adjacent trigger keyboard focus survives switching without focusing either terminal', async () => {
const b = browser({role:'admin'}); b.start(); await tick();
b.nodes['quick-network'].focus(); await tick();
+93 -8
View File
@@ -52,6 +52,19 @@ def check_layout(html):
assert trigger['attrs']['aria-expanded'] == 'false'
assert trigger['attrs']['aria-haspopup'] == 'dialog'
assert trigger['attrs']['aria-label'] and 'disabled' in trigger['attrs']
assert {'status-item', 'quick-trigger'} <= set(classes(trigger)), domain
assert 'status-grid' in classes(trigger['parent']), 'the button itself must be the grid card'
assert [classes(n) for n in trigger['children']] == [['label'], ['value']], domain
assert all(n['tag'] == 'span' for n in trigger['children'])
value_id = {'serial': 'serial-summary', 'network': 'wifi-summary', 'broker': 'broker-clients', 'writer': 'writer-id'}[domain]
assert ids[value_id]['parent'] is trigger
assert ('wide' in classes(trigger)) == (domain in ('serial', 'network'))
assert not any(n['tag'] in ('button', 'a', 'input', 'select') for n in descendants(trigger))
options = ids['network-target']['children']
assert [n['attrs']['value'] for n in options] == ['ap', '0', '1', '2', '3']
for i, option in enumerate(options[1:]):
assert ids['network-profile-' + str(i)] is option
assert 'hidden' not in option['attrs'] and 'disabled' not in option['attrs']
assert ids['quick-full']['tag'] == 'a' and ids['quick-full']['attrs']['href'] == '#serial-settings'
assert 'hidden' in ids['quick-header']['attrs']
assert ids['network-password']['parent'] is ids['network-password-label']
@@ -87,6 +100,19 @@ def check_layout(html):
assert html.index('id="account-delete"') < html.index('id="account-result"') < html.index('id="account-key-add"') < html.index('id="account-submit-password"')
css = next(n['text'] for n in descendants(doc.root) if n['tag'] == 'style')
for rule in (
'.quick-trigger{display:block;width:100%;color:inherit;text-align:left;font:inherit;cursor:pointer;overflow:hidden}',
'.status-grid{min-width:0;padding:16px;display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:12px}',
'.status-grid{grid-template-columns:repeat(2,minmax(0,1fr))}',
'.status-item{min-width:0;',
'.value{display:block;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;',
"[data-quick='true'] #quick-header>h2{display:block}",
"[data-quick='true'] #settings-values>:nth-child(n+3){display:none}",
"[data-quick='true'] #network-edit>*{display:none}",
"[data-quick='true'] #network-edit>.settings-edit>label{display:none}",
"[data-quick='true'] #network-edit>.settings-edit>#network-target-label,[data-quick='true'] #network-edit>.settings-edit>#network-policy-label{display:grid}",
"[data-quick='true'] #network-edit>.settings-edit>#network-enabled-label{display:flex}",
"[data-quick='true'] #network-wifi-load{display:none}",
'.settings-values dd{margin:0;white-space:pre-wrap;overflow-wrap:anywhere}',
'.settings-values{display:grid;grid-template-columns:minmax(110px,1fr) minmax(0,2fr);gap:8px 16px;max-width:600px}',
'.serial-edit,.settings-edit{display:grid;grid-template-columns:repeat(auto-fit,minmax(160px,1fr));gap:12px;max-width:600px}',
@@ -100,8 +126,18 @@ def check_layout(html):
'.settings-edit{grid-template-columns:minmax(0,1fr)}',
):
assert rule in css, rule
# These selectors must share a hide rule, not merely occur in explanatory text.
import re
hide_selectors = {selector.strip() for selectors in re.findall(r'([^{}]+)\{display:none\}', css) for selector in selectors.split(',')}
for selector in ('#settings-navigation', '#quick-help', 'h3', '>div>h2', 'p:not([role=status])', '#network-summary', '#broker-values'):
scoped = "[data-quick='true']" + ('' if selector.startswith('>') else ' ') + selector
assert scoped in hide_selectors, scoped
for ident in ('settings-detail', 'serial-operation-detail', 'network-detail', 'network-operation-detail', 'broker-detail', 'broker-operation-detail'):
assert ids[ident]['attrs']['role'] == 'status', ident
assert [n['tag'] for n in ids['settings-values']['children'][:2]] == ['dt', 'dd']
assert ids['settings-values']['children'][0]['text'] == 'Service'
assert '.settings-edit textarea{font:inherit;width:100%;min-width:0;' in css
print('PASS HTML layout: parsed structure, shared styles, labels, wrapping, checkbox sizing and action order across all seven settings views')
print('PASS HTML layout: full-card triggers, compact quick CSS, profile slots, status roles and shared settings structure/styles')
def check_browser_layout(html, tmp, executable):
@@ -114,15 +150,45 @@ def check_browser_layout(html, tmp, executable):
fixture = re.sub(r'<link\b[^>]*>|<img\b[^>]*>', '', fixture)
probe = r'''
const cases = [];
for (const width of [320, 600, 1200]) for (const view of ['serial-settings-content', 'account-settings', 'network-settings', 'display-settings']) for (const quick of [false, true]) {
if (quick && !['serial-settings-content', 'network-settings'].includes(view)) continue;
const widths = [320, 600, 900, 1200];
const views = ['serial-settings-content', 'account-settings', 'network-settings', 'display-settings', 'broker-settings'];
for (const width of widths) {
const frame = document.createElement('iframe'); frame.style.width = width + 'px'; frame.style.height = '900px';
cases.push(new Promise(resolve => {
frame.onload = () => {
const d = frame.contentDocument, win = frame.contentWindow, errors = [];
for (const id of ['quick-serial', 'quick-network', 'quick-broker', 'quick-writer']) {
const card = d.getElementById(id), value = card.querySelector('.value');
card.disabled = false; value.textContent = 'long-unbroken-status-'.repeat(20);
}
const grid = d.querySelector('.status-grid'), gridRect = grid.getBoundingClientRect();
if (grid.scrollWidth > grid.clientWidth + 1 || gridRect.left < 0 || gridRect.right > width + 1) errors.push('status grid overflow');
for (const id of ['quick-serial', 'quick-network', 'quick-broker', 'quick-writer']) {
const card = d.getElementById(id), value = card.querySelector('.value');
const r = card.getBoundingClientRect(), v = value.getBoundingClientRect(), style = win.getComputedStyle(value);
if (r.width <= 0 || r.left < gridRect.left || r.right > gridRect.right + 1 || card.scrollWidth > card.clientWidth + 1) errors.push('card overflow:' + id);
if (v.left < r.left || v.right > r.right || value.scrollWidth <= value.clientWidth) errors.push('missing constrained long value:' + id);
if (style.textOverflow !== 'ellipsis' || style.whiteSpace !== 'nowrap' || style.overflowX !== 'hidden') errors.push('missing ellipsis:' + id);
// Probe card padding/corners as well as label/value: a nested text-only button must fail.
for (const [x, y] of [[r.left + 6, r.top + 6], [r.right - 6, r.bottom - 6], [r.left + r.width / 2, r.top + r.height / 2], [v.left + 1, v.top + 1]]) {
const hit = d.elementFromPoint(x, y);
if (hit?.closest('button') !== card) errors.push('card hit target:' + id + ' at ' + x + ',' + y + ' hit ' + (hit?.id || hit?.tagName || 'nothing'));
}
}
resolve({width, view:'status-cards', errors});
};
}));
frame.srcdoc = FIXTURE; document.body.append(frame);
}
for (const width of widths) for (const view of views) for (const quick of [false, true]) {
if (quick && !['serial-settings-content', 'network-settings', 'broker-settings'].includes(view)) continue;
const height = quick ? 360 : 900;
const frame = document.createElement('iframe'); frame.style.width = width + 'px'; frame.style.height = height + 'px';
cases.push(new Promise(resolve => {
frame.onload = () => {
const d = frame.contentDocument, win = frame.contentWindow;
d.getElementById('serial-settings').hidden = false;
for (const id of ['serial-settings-content', 'account-settings', 'network-settings', 'display-settings']) d.getElementById(id).hidden = id !== view;
for (const id of views) d.getElementById(id).hidden = id !== view;
const section = d.getElementById(view);
section.querySelectorAll('[hidden]').forEach(n => n.hidden = false);
section.querySelectorAll('dl').forEach(dl => {
@@ -142,9 +208,27 @@ def check_browser_layout(html, tmp, executable):
if (rect.left < 0 || rect.top < 0 || rect.right > width || rect.bottom > height) errors.push('popover viewport overflow');
if (host.scrollWidth > host.clientWidth + 1) errors.push('popover horizontal overflow');
if (win.getComputedStyle(host).overflowY !== 'auto') errors.push('popover not scrollable');
for (const id of ['settings-navigation', 'network-password-mode-label', 'network-password-label', 'network-password-status']) {
for (const id of ['settings-navigation', 'network-password-mode-label', 'network-password-label', 'network-password-status', 'network-summary', 'broker-values']) {
if (d.getElementById(id).getBoundingClientRect().width) errors.push('quick hidden content:' + id);
}
const visible = node => !!node.getBoundingClientRect().width;
for (const node of section.querySelectorAll('p:not([role=status]),h2,h3')) if (visible(node)) errors.push('quick explanatory content:' + node.tagName);
if (!visible(d.getElementById('quick-title'))) errors.push('missing quick title');
const statuses = { 'serial-settings-content':['settings-detail','serial-operation-detail'], 'network-settings':['network-detail','network-operation-detail'], 'broker-settings':['broker-detail','broker-operation-detail'] };
for (const id of statuses[view]) if (!visible(d.getElementById(id))) errors.push('hidden result/status:' + id);
if (view === 'serial-settings-content') {
const rows = [...d.getElementById('settings-values').children];
if (!rows.every((node, i) => visible(node) === (i < 2))) errors.push('quick serial must show only service pair');
}
if (view === 'network-settings') {
const allowed = ['network-target','network-policy','network-enabled','network-apply','network-wifi-save','network-result','network-refresh'];
const controls = [...section.querySelectorAll('input,select,textarea,button')];
for (const node of controls) if (visible(node) !== allowed.includes(node.id)) errors.push('quick control visibility:' + node.id);
// CSS-only promotion restores the full editor; application draft/option state is checked in Node.
host.dataset.quick = 'false';
for (const node of controls) if (!visible(node)) errors.push('full control not restored:' + node.id);
host.dataset.quick = 'true';
}
}
for (const node of section.querySelectorAll('dl,input,select,textarea,.settings-edit,.serial-edit,.serial-actions')) {
const rect = node.getBoundingClientRect();
@@ -190,6 +274,7 @@ def check_browser_layout(html, tmp, executable):
assert result.returncode == 0, result.stderr
parsed = Document(result.stdout)
results = json.loads(parsed.ids['layout-results']['text'])
assert len(results) == 18
assert all(not case['errors'] for case in results), results
print('PASS Chromium layout: four full views plus Serial/Wi-Fi popovers at 320/600/1200px (quick height360); viewport/overflow/secret exclusion and full-page whitespace checks (fixtures, not live app)')
assert len(results) == 36
failures = [case for case in results if case['errors']]
assert not failures, failures
print('PASS Chromium layout: full-card hit targets/ellipsis, five full views and Serial/Wi-Fi/Broker popovers at 320/600/900/1200px (quick height360); compact controls/status, promotion restoration and whitespace checks (fixtures, not live app)')
+46 -3
View File
@@ -35,16 +35,59 @@ module.exports = async ({test, browser, adminBrowser, tick, json, session, failu
assert.equal(reads(b).length,1); assert.equal(posts(b).length,0); clean(b);
assert.ok(n(b,'password-mode').disabled);
target(b,'0'); assert.equal(posts(b).length,0); clean(b);
input(b,'ssid','new network');
n(b,'enabled').checked = false; n(b,'enabled').change();
b.queues[operation].push(ack('profile-patch')); b.click('network-apply'); await tick();
assert.equal(posts(b).length,1);
const body = JSON.parse(posts(b)[0].body); assert.equal(body.action,'profile-patch');
assert.ok(!Object.hasOwn(body,'password')); clean(b);
assert.deepEqual(JSON.parse(posts(b)[0].body), {action:'profile-patch', generation:7, profile:0, enabled:false});
clean(b);
b.click('quick-close'); await tick(); assert.equal(n(b,'ssid').value,''); clean(b);
b.queues[path].push(json(fixture())); b.click('quick-network'); await tick();
assert.equal(posts(b).length,1); assert.ok(n(b,'apply').disabled);
assert.match(n(b,'operation-detail').textContent,/pending|unknown|uncertain/);
});
await test('Quick Wi-Fi names configured profile slots, hides only empty slots and restores all slots on promotion', async () => {
const value = fixture();
value.wifi.profiles[2].ssid = '<b>office backup</b>'; // Configured but disabled remains selectable; label is safe text.
value.wifi.profiles[2].password_configured = true;
const b = await adminBrowser(); b.queues[path].push(json(value)); b.click('quick-network'); await tick();
const slots = quick => {
for (let i = 0; i < 4; i++) {
const option = n(b, 'profile-' + i), empty = !value.wifi.profiles[i].ssid;
assert.ok(option, 'missing profile option ' + i);
assert.equal(option.hidden, quick && empty, 'profile visibility ' + i);
assert.equal(option.disabled, quick && empty, 'profile selectability ' + i);
if (!empty) assert.ok(option.textContent.includes(value.wifi.profiles[i].ssid), 'SSID label ' + i);
assert.equal(option.children.length, 0, 'SSID must not become markup');
}
};
slots(true); assert.equal(n(b,'target').value, 'ap');
target(b,'2'); n(b,'enabled').checked = true; n(b,'enabled').change();
const selectedOption = n(b,'profile-2'), count = b.calls.length;
b.nodes['quick-full'].click({preventDefault(){}}); await tick();
slots(false); assert.equal(n(b,'profile-2'), selectedOption);
assert.equal(n(b,'target').value, '2'); assert.equal(n(b,'enabled').checked, true);
assert.equal(n(b,'ssid').value, value.wifi.profiles[2].ssid);
assert.equal(b.nodes['terminal-title'].textContent, 'Settings');
assert.equal(b.calls.length, count, 'promotion must preserve the draft without fetching');
target(b,'3'); assert.equal(n(b,'ssid').value, '', 'full page can configure an empty slot');
b.click('select-admin'); b.queues[path].push(json(value)); b.click('quick-network'); await tick(); slots(true);
value.wifi.profiles[2].ssid = ''; value.wifi.profiles[2].password_configured = false;
value.wifi.profiles[3].ssid = 'replacement'; value.wifi.profiles[3].password_configured = true;
b.queues[path].push(json(value)); b.click('network-refresh'); await tick(); slots(true);
assert.equal(posts(b).length, 0, 'opening, selecting, promotion and refresh never apply'); clean(b);
});
await test('Quick Wi-Fi AP policy applies only its typed patch; Save persists working values, not the draft', async () => {
for (const action of ['wifi-patch', 'wifi-save']) {
const b = await adminBrowser(); b.queues[path].push(json(fixture())); b.click('quick-network'); await tick();
assert.equal(n(b,'target').value, 'ap');
input(b,'policy','always','change');
assert.equal(posts(b).length, 0);
b.queues[operation].push(ack(action)); b.click(action === 'wifi-patch' ? 'network-apply' : 'network-wifi-save'); await tick();
assert.equal(posts(b).length, 1);
assert.deepEqual(JSON.parse(posts(b)[0].body), action === 'wifi-patch' ? {action, generation:7, ap_policy:'always'} : {action, generation:7});
clean(b);
}
});
await test('Quick Network rejects extra secret fields, fences401 and keeps full-page secret draft out of hover', async () => {
const b = await open(); secret(b); const count = b.calls.length;
b.nodes['quick-serial'].pointerenter({pointerType:'mouse'}); await tick();