Add Serial and Wi-Fi quick settings

Reuse the existing settings view and typed controllers for accessible
quick editing while keeping network credentials out of quick mode.
Expand browser and layout coverage for focus, dismissal, bounds, and
expiry.
This commit is contained in:
2026-09-13 14:22:31 +02:00
parent add399908a
commit 9f6ebf2053
10 changed files with 355 additions and 21 deletions
+65 -10
View File
@@ -26,6 +26,9 @@ 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"
".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"
"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,"
@@ -154,9 +157,9 @@ static const char s_index_html[] =
"<div class=\"status-item\"><span class=\"label\">Broker clients</span>"
"<span id=\"broker-clients\" class=\"value\">—</span></div>\n"
"<div class=\"status-item wide\"><span class=\"label\">Wi-Fi</span>"
"<span id=\"wifi-summary\" class=\"value\">Loading…</span></div>\n"
"<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>"
"<span id=\"serial-summary\" class=\"value\">Loading…</span></div>\n"
"<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"
"</div>\n"
"<div class=\"panel controls\">\n"
"<div class=\"button-row\">\n"
@@ -183,7 +186,8 @@ static const char s_index_html[] =
"<div id=\"terminal\" class=\"terminal-host\"></div>\n"
"<div id=\"admin-terminal\" class=\"terminal-host\" hidden></div>\n"
"<section id=\"serial-settings\" class=\"settings-page\" aria-label=\"Serial settings\" hidden>"
"<div class=\"serial-actions\"><button id=\"settings-serial\" class=\"button\" type=\"button\" aria-pressed=\"true\">Serial settings</button>"
"<div id=\"quick-header\" hidden><h2 id=\"quick-title\">Quick settings</h2><button id=\"quick-close\" class=\"button\" type=\"button\">Close quick settings</button> <a id=\"quick-full\" href=\"#serial-settings\">Open full settings page</a><p class=\"connection-detail\">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.</p></div>"
"<div id=\"settings-navigation\" class=\"serial-actions\"><button id=\"settings-serial\" class=\"button\" type=\"button\" aria-pressed=\"true\">Serial settings</button>"
"<button id=\"settings-accounts\" class=\"button\" type=\"button\" aria-pressed=\"false\">Accounts</button>"
"<button id=\"settings-network\" class=\"button\" type=\"button\" aria-pressed=\"false\">Network</button>"
"<button id=\"settings-display\" class=\"button\" type=\"button\" aria-pressed=\"false\">Display</button><button id=\"settings-broker\" class=\"button\" type=\"button\" aria-pressed=\"false\">Broker</button></div>"
@@ -224,9 +228,9 @@ static const char s_index_html[] =
"<label id=\"network-security-label\">Security<select id=\"network-security\"><option value=\"mixed\">WPA2 or stronger (mixed)</option><option value=\"wpa3\">WPA3</option></select></label>"
"<label>SSID editor<select id=\"network-ssid-mode\"><option value=\"text\">Ordinary text (UTF-8)</option><option value=\"hex\">Exact bytes (hex)</option></select></label>"
"<label>SSID<input id=\"network-ssid\" maxlength=\"256\" autocomplete=\"off\" spellcheck=\"false\"></label>"
"<label>Password action<select id=\"network-password-mode\"><option value=\"keep\">Keep existing (default)</option>"
"<label id=\"network-password-mode-label\">Password action<select id=\"network-password-mode\"><option value=\"keep\">Keep existing (default)</option>"
"<option value=\"replace\">Replace</option><option id=\"network-password-clear\" value=\"clear\">Clear (disabled STA only)</option></select></label>"
"<label>New password<input id=\"network-password\" type=\"password\" maxlength=\"64\" autocomplete=\"new-password\" disabled></label></div>"
"<label id=\"network-password-label\">New password<input id=\"network-password\" type=\"password\" maxlength=\"64\" autocomplete=\"new-password\" disabled></label></div>"
"<p id=\"network-password-status\" class=\"connection-detail\"></p><p id=\"network-ssid-detail\" class=\"connection-detail\" role=\"status\"></p>"
"<p class=\"connection-detail\">SSID maximum: 32 bytes, not characters. Text entry encodes UTF-8 once; hex preserves arbitrary bytes including zero. "
"Switching modes is lossless or refused. Password replacement requires 863 printable ASCII characters; blank never clears. "
@@ -393,7 +397,56 @@ static const char s_app_js[] =
" element('refresh-settings').disabled = busy;\n"
" element('serial-result').disabled = busy;\n"
"}\n"
"let quick = null, quickLeaveTimer = 0, quickSuppressFocus = false, quickDismissed = null;\n"
"function resetQuick() {\n"
" window.clearTimeout(quickLeaveTimer); quickLeaveTimer = 0; quick = null;\n"
" settingsHost.setAttribute('data-quick', 'false'); settingsHost.setAttribute('role', 'region');\n"
" element('quick-header').hidden = true; settingsHost.setAttribute('aria-label', 'Settings');\n"
" for (const domain of ['serial','network']) element('quick-' + domain).setAttribute('aria-expanded', 'false');\n"
"}\n"
"function closeQuick(focus = false) {\n"
" if (!quick) return;\n"
" const {previous, trigger} = quick; quickDismissed = trigger; resetQuick(); selectTerminal(previous, true, false);\n"
" if (focus) { quickSuppressFocus = true; trigger.focus(); quickSuppressFocus = false; }\n"
"}\n"
"function openQuick(domain, activate = false) {\n"
" if (quickSuppressFocus || !sessionVerified || accountRole !== 'admin' || suspended || unloading || navigating || loggingOut) return;\n"
" if (!activate && quickDismissed === element('quick-' + domain)) return;\n"
" if (!activate && quick && settingsHost.contains(document.activeElement)) return;\n"
" if (activate) quickDismissed = null;\n"
" if (quick?.domain === domain) { if (activate) element('quick-close').focus(); return; }\n"
" if (quick) closeQuick();\n"
" // Full-page drafts retain their sole DOM owner; only explicit activation navigates there.\n"
" if (selected === 'settings') { if (activate) { selectSettingsDomain(domain); element('settings-' + domain).focus(); } return; }\n"
" const previous = selected, trigger = element('quick-' + domain);\n"
" selectTerminal('settings', false); selectSettingsDomain(domain, false); clearNetworkSecret();\n"
" quick = {domain, previous, trigger}; settingsHost.setAttribute('data-quick', 'true');\n"
" settingsHost.setAttribute('role', 'dialog'); settingsHost.setAttribute('aria-label', domain === 'serial' ? 'Serial quick settings' : 'Wi-Fi quick settings');\n"
" element('quick-title').textContent = domain === 'serial' ? 'Serial quick settings' : 'Wi-Fi quick settings';\n"
" element('quick-header').hidden = false; trigger.setAttribute('aria-expanded', 'true');\n"
" terminalHost.hidden = previous !== 'serial'; adminHost.hidden = previous !== 'admin';\n"
" refreshSettings(); if (activate) element('quick-close').focus();\n"
"}\n"
"function quickContains(target) { return !!quick && !!target && (settingsHost.contains(target) || quick.trigger.contains(target)); }\n"
"function quickLeave() {\n"
" window.clearTimeout(quickLeaveTimer); if (!quick) return;\n"
" quickLeaveTimer = window.setTimeout(() => { quickLeaveTimer = 0; if (quick && !quickContains(document.activeElement) && !settingsHost.matches(':hover') && !quick.trigger.matches(':hover')) closeQuick(); }, 250);\n"
"}\n"
"for (const domain of ['serial','network']) {\n"
" const trigger = element('quick-' + domain);\n"
" trigger.addEventListener('pointerenter', event => { if (event.pointerType === 'mouse') openQuick(domain); });\n"
" trigger.addEventListener('focus', () => openQuick(domain));\n"
" trigger.addEventListener('click', () => openQuick(domain, true));\n"
" trigger.addEventListener('pointerleave', () => { if (quickDismissed === trigger) quickDismissed = null; quickLeave(); }); trigger.addEventListener('focusout', () => { if (quickDismissed === trigger) quickDismissed = null; quickLeave(); });\n"
"}\n"
"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"
"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"
" resetQuick();\n"
" clearAccounts(); clearNetwork(); clearDisplay(); clearBroker();\n"
" if (!serialAuto && serialOperationPending) element('serial-operation-detail').textContent = serialOutcomeWarning + 'Operation outcome pending or unknown. Select Check Result on return; navigation does not cancel backend work.';\n"
" stopSerialAuto(true);\n"
@@ -870,7 +923,7 @@ static const char s_app_js[] =
" if (accountPending) element('account-operation-detail').textContent = accountWarning + 'Outcome pending or unknown. Check Result on return; navigation does not cancel backend work.';\n"
" accountButtons();\n"
"}\n"
"function selectSettingsDomain(domain) {\n"
"function selectSettingsDomain(domain, read = true) {\n"
" if (!sessionVerified || accountRole !== 'admin' || selected !== 'settings' || domain === settingsDomain) return;\n"
" clearSettings(); settingsDomain = domain; settingsHost.hidden = false;\n"
" element('serial-settings-content').hidden = domain !== 'serial'; element('account-settings').hidden = domain !== 'accounts'; element('network-settings').hidden = domain !== 'network';\n"
@@ -878,7 +931,7 @@ static const char s_app_js[] =
" element('broker-settings').hidden = domain !== 'broker'; element('settings-broker').setAttribute('aria-pressed', String(domain === 'broker'));\n"
" element('settings-network').setAttribute('aria-pressed', String(domain === 'network'));\n"
" element('settings-serial').setAttribute('aria-pressed', String(domain === 'serial')); element('settings-accounts').setAttribute('aria-pressed', String(domain === 'accounts'));\n"
" refreshSettings();\n"
" if (read) refreshSettings();\n"
"}\n"
"async function refreshAccounts() {\n"
" if (!accountsLive() || accountsAbort) return;\n"
@@ -1040,6 +1093,7 @@ static const char s_app_js[] =
" net('password').disabled = blocked || net('password-mode').value !== 'replace';\n"
" 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"
"}\n"
"function clearNetwork() {\n"
" clearNetworkSecret();\n"
@@ -1295,7 +1349,7 @@ static const char s_app_js[] =
" adminDetail.textContent = detail;\n"
" updateControls();\n"
"}\n"
"function selectTerminal(mode) {\n"
"function selectTerminal(mode, read = true, focus = true) {\n"
" if (mode === selected) return;\n"
" if (unloading || navigating || loggingOut || !sessionVerified || (mode !== 'serial' && accountRole !== 'admin')) return;\n"
" clearSettings();\n"
@@ -1320,7 +1374,7 @@ static const char s_app_js[] =
" 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') refreshSettings(); else (mode === 'serial' ? terminal : adminTerminal).focus();\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"
@@ -1493,8 +1547,9 @@ static const char s_app_js[] =
" accountRole = payload.role;\n"
" element('terminal-selector').hidden = accountRole !== 'admin';\n"
" if (accountRole !== 'admin') { closeAdmin(); selectTerminal('serial'); }\n"
" terminalHost.hidden = selected !== 'serial'; adminHost.hidden = selected !== 'admin';\n"
" terminalHost.hidden = (quick?.previous || selected) !== 'serial'; adminHost.hidden = (quick?.previous || selected) !== 'admin';\n"
" settingsHost.hidden = selected !== 'settings' || loggingOut || suspended;\n"
" for (const domain of ['serial','network']) element('quick-' + domain).disabled = accountRole !== 'admin' || loggingOut || suspended;\n"
" scheduleFit();\n"
" const deadline = Date.now() + payload.expires_in * 1000;\n"
" sessionDeadline = sessionDeadline ? Math.min(sessionDeadline, deadline) : deadline;\n"