diff --git a/docs/agent/current-state.md b/docs/agent/current-state.md index 9f7aec9..5e71fdb 100644 --- a/docs/agent/current-state.md +++ b/docs/agent/current-state.md @@ -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. diff --git a/src/web_ui.c b/src/web_ui.c index 179e7f9..1c54e88 100644 --- a/src/web_ui.c +++ b/src/web_ui.c @@ -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[] = "Observer\n" "
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.
Explicit confirmation required. After submission use Check Operation Result, then Refresh. Navigation or timeout does not cancel admitted work. No automatic mutation retry.
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.
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.
Explicit confirmation required. Navigation or timeout does not cancel admitted work. Check Result after uncertainty; no automatic mutation retry.
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.
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.
Explicit confirmation required. Navigation or timeout does not cancel admitted work. Check Result after uncertainty; no automatic mutation retry.
Working OLED inactivity settings, not saved NVS values. Zero disables a transition. Each timeout is 0–86400 seconds; when both are enabled, Off must be later than Dim.
\n" "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.
\n" @@ -227,9 +227,9 @@ static const char s_index_html[] = "" "After uncertainty, Check Result and Refresh. Never assume timeout or navigation cancels work; no automatic mutation retry.
After uncertainty, Check Result and Refresh. Never assume timeout or navigation cancels work; no automatic mutation retry.
Working UART1 configuration below is not a saved NVS snapshot. " "Navigation leaves both terminals connected and preserves the serial writer lease.
" "" @@ -296,7 +296,7 @@ static const char s_index_html[] = "" "Check Result after any uncertain submission; never assume timeout or navigation cancels an operation.
Check Result after any uncertain submission; never assume timeout or navigation cancels an operation.
" "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.
" @@ -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" diff --git a/tests/web_ui_session/README.md b/tests/web_ui_session/README.md index 831157a..a19036c 100644 --- a/tests/web_ui_session/README.md +++ b/tests/web_ui_session/README.md @@ -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: diff --git a/tests/web_ui_session/browser.cjs b/tests/web_ui_session/browser.cjs index 9e3ece7..d10b60e 100644 --- a/tests/web_ui_session/browser.cjs +++ b/tests/web_ui_session/browser.cjs @@ -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(); diff --git a/tests/web_ui_session/layout.py b/tests/web_ui_session/layout.py index c0f7b2c..83377d1 100644 --- a/tests/web_ui_session/layout.py +++ b/tests/web_ui_session/layout.py @@ -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']*>|