diff options
Diffstat (limited to 'modules/core/module-uishell.js')
| -rw-r--r-- | modules/core/module-uishell.js | 64 |
1 files changed, 53 insertions, 11 deletions
diff --git a/modules/core/module-uishell.js b/modules/core/module-uishell.js index ac14b20..9914ec0 100644 --- a/modules/core/module-uishell.js +++ b/modules/core/module-uishell.js @@ -18,6 +18,7 @@ class UIShellModule { this._root = null; this._cleanup = null; this._where = null; // [{ el, parent, next }] captured on first mount + this._loadSeq = 0; // bumps on every apply(): a stale lazy load must never win this._terminalEl = null; // identity of the one real output node this._cssIds = []; this._pending = null; // mode requested before its ext module loaded @@ -49,9 +50,13 @@ class UIShellModule { let wanted = localStorage.getItem(this.key) || 'classic'; try { const q = new URLSearchParams(location.search).get('ui'); - if (q) { wanted = q; this._fromQuery = true; } + // Validate before it reaches classList.add('wx11-ui-' + id): a space or a colon in the + // query throws a DOMException there and boot dies with no interface at all. + if (q && /^[a-z][a-z0-9-]{0,15}$/.test(q)) { wanted = q; this._fromQuery = true; } } catch (e) { /* no URLSearchParams: ignore the request */ } - const applied = this.apply(wanted, { silent: true, persist: this._fromQuery }); + // persist:false — a shared link requests a view, it must not rewrite the user's own default + // (the code comment already claimed this; the flag did the opposite). + const applied = this.apply(wanted, { silent: true, persist: false }); if (!applied) this._pending = wanted; // ext modules register ~800 ms later // A requested/stored mode that never registers (404, parse error) is forgotten rather than @@ -103,6 +108,9 @@ class UIShellModule { apply(id, opts) { const o = opts || {}; const want = id || 'classic'; + // Every apply supersedes an in-flight lazy load. Without this token a load that finishes + // after the user moved on force-applies the abandoned mode (two reviewers, same finding). + const token = ++this._loadSeq; // A user-initiated switch cancels a pending boot restore, or a slow ext module would later // yank the interface back to whatever boot asked for. if (!o.silent) { this._pending = null; this._closePicker(); } @@ -117,12 +125,15 @@ class UIShellModule { this._pending = want; if (this.terminal) this.toast('Loading ' + want + '…', 1500); this._loadMode(want).then((ok) => { + // registerMode() applies the mode the moment it registers — which already bumped this + // token — and so does a user switch. Only act while this load is still the newest. + if (this._loadSeq !== token) return; if (ok) this.apply(want, { force: true, retried: true, persist: o.persist }); else if (this.terminal) { this.terminal.print(`UI mode '${want}' could not be loaded — available: ` + (['classic'].concat(Object.keys(this.modes)).join(', ')), 'warning'); } - }); + }).catch((e) => this._loadFail(want, 'load rejected: ' + (e && e.message))); return false; } if (want === this.current && !o.force) { @@ -137,6 +148,11 @@ class UIShellModule { this._teardown(); this.current = want; document.body.dataset.uiMode = want; + // Drop the previous mode's class: two wx11-ui-* classes at once keep any rule keyed on the + // old one matching, and the next switch then has to clean up after two modes. + Array.from(document.body.classList) + .filter((c) => c.indexOf('wx11-ui-') === 0 && c !== 'wx11-ui-' + want) + .forEach((c) => document.body.classList.remove(c)); document.body.classList.add('wx11-ui-' + want); if (o.persist !== false) localStorage.setItem(this.key, want); @@ -162,6 +178,9 @@ class UIShellModule { return false; } this._dockFooter(); + // Every mode hides .prompt-label, so without this the real input has no accessible name. + const inp = document.getElementById('command-input'); + if (inp && !inp.getAttribute('aria-label')) inp.setAttribute('aria-label', 'Terminal command input'); document.dispatchEvent(new CustomEvent('wx11:ui-changed', { detail: { mode: want } })); return true; } @@ -170,6 +189,10 @@ class UIShellModule { if (this.terminal) this.terminal.print(`UI mode '${id}' ${why} — reverted to classic.`, 'error'); try { if (this._cleanup) this._cleanup(); } catch (e) { /* mode is already broken */ } this._cleanup = null; + this._pending = null; // a sweep or an in-flight load must not re-apply it + // Roll the stored preference back: leaving the broken mode persisted means every reload fails + // the same way, with no way out except devtools. + try { localStorage.setItem(this.key, 'classic'); } catch (e) { /* private mode */ } this._restoreStock(); if (this._root) this._root.innerHTML = ''; this.current = 'classic'; @@ -248,16 +271,25 @@ class UIShellModule { _snapshotStock() { if (this._where) return; this._terminalEl = document.getElementById('terminal-container'); - this._where = this._stockEls().map(el => ({ el, parent: el.parentNode, next: el.nextSibling || null })); + this._where = this._stockEls().map(el => ({ + el, + parent: el.parentNode, + index: Array.prototype.indexOf.call(el.parentNode.children, el), + })); } _restoreStock() { if (!this._where) return; - for (const w of this._where) { - if (!w.parent) continue; - const ref = w.next && w.next.parentNode === w.parent ? w.next : null; - try { w.parent.insertBefore(w.el, ref); } catch (e) { /* detached parent */ } - } + // Ascending original index, anchored on the next stock node that is ALREADY home. Neither + // `nextSibling` (a mode moves several nodes, so the recorded sibling lives inside the mode) + // nor a raw child index (removing nodes shifts every later index) reproduces the order — + // measured: `body.children[idx]` gave terminal,input,status-bar,quick-actions. + const list = this._where.slice().sort((a, b) => a.index - b.index); + list.forEach((w) => { + if (!w.parent || w.el.parentNode === w.parent) return; // already in place + const anchor = list.find((c) => c.index > w.index && c.el.parentNode === w.parent); + try { w.parent.insertBefore(w.el, anchor ? anchor.el : null); } catch (e) { /* detached */ } + }); } /* ---------- chrome ---------- */ @@ -391,7 +423,9 @@ class UIShellModule { // Do NOT use terminal._parseArgs — it does not exist on the terminal instance; every // panel rendered "unavailable: term._parseArgs is not a function" (measured 2026-11-07). const raw = String(line).trim(); - const parts = raw.match(/(?:[^\s"']+|"[^"]*"|'[^']*')+/g) || []; + // Strip the quoting characters: capturePipeOutput receives argv, not shell words. + const parts = (raw.match(/(?:[^\s"']+|"[^"]*"|'[^']*')+/g) || []) + .map((a) => a.replace(/^["']/, '').replace(/["']$/, '')); const cmd = parts[0]; if (!cmd) return Promise.resolve(''); return term.capturePipeOutput(cmd, parts.slice(1), raw); @@ -445,15 +479,23 @@ class UIShellModule { const self = this; const wrap = document.createElement('div'); wrap.id = 'wx11-ui-picker'; + // A full-screen overlay that is not announced as a dialog is a keyboard trap in reverse: Tab + // walks straight into the page behind it. (Found by review.) + wrap.setAttribute('role', 'dialog'); + wrap.setAttribute('aria-modal', 'true'); + wrap.setAttribute('aria-labelledby', 'wx11-ui-picker-title'); wrap.style.cssText = 'position:fixed;inset:0;z-index:9998;background:rgba(0,0,0,.86);' + 'display:flex;flex-direction:column;justify-content:center;padding:20px;overflow:auto;gap:10px;'; wrap.innerHTML = '<div style="max-width:720px;margin:0 auto;width:100%">' + - '<h2 style="margin:0 0 4px;font-size:1.15em">How do you want newOS4 to look?</h2>' + + '<h2 id="wx11-ui-picker-title" style="margin:0 0 4px;font-size:1.15em">How do you want newOS4 to look?</h2>' + '<p class="wx11-dim" style="margin:0 0 14px">Pick one — you can switch any time with <code>ui</code>.</p>' + '<div id="wx11-ui-picker-list" style="display:grid;gap:10px"></div>' + '<button class="wx11-btn" id="wx11-ui-picker-skip" style="margin-top:12px;width:100%;text-align:center">' + 'Keep the classic terminal</button></div>'; document.body.appendChild(wrap); + // Focus the first card so Escape/Tab/Enter work without a pointer. + const firstCard = wrap.querySelector('#wx11-ui-picker-list button'); + if (firstCard && firstCard.focus) firstCard.focus(); const list = wrap.querySelector('#wx11-ui-picker-list'); // Fewer than 4 registered means the ext modules are still in flight — use the static list so // the user never sees a one-card chooser (the card handler re-checks and warns if missing). |