diff options
Diffstat (limited to 'modules/core/module-uishell.js')
| -rw-r--r-- | modules/core/module-uishell.js | 607 |
1 files changed, 607 insertions, 0 deletions
diff --git a/modules/core/module-uishell.js b/modules/core/module-uishell.js new file mode 100644 index 0000000..94e9bd1 --- /dev/null +++ b/modules/core/module-uishell.js @@ -0,0 +1,607 @@ +// newOS4 UI shell (v4.6.152) — one output stream, seven chromes. +// +// Rules this file exists to enforce: +// • modes REPARENT the stock nodes (#terminal-container, #input-area, #quick-actions, +// #status-bar); they never recreate them. terminal.js holds those references from +// construction, so a fresh node silently orphans the AI output. +// • teardown restores the stock nodes and clears the mode's DOM; the post-mount check compares +// node IDENTITY, because a same-id clone passes a presence check. +// • the shell, not the mode, docks #status-bar (it holds the AI stop button) into #ui-foot. +// • ext modules are not a sandbox — they already hold window.webx11. The api() below is +// ergonomics, not a privilege boundary. Do not "harden" it into theatre. +class UIShellModule { + constructor() { + this.commands = {}; + this.terminal = null; + this.modes = {}; // id -> { id, label, level, icon, tagline, mount } + this.current = 'classic'; + 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._loading = {}; // id -> in-flight promise, so a double click loads a mode once + this._terminalEl = null; // identity of the one real output node + this._cssIds = []; + this._pending = null; // mode requested before its ext module loaded + this._picker = null; + this._fromQuery = false; + this.key = 'webx11_ui_mode'; + this.seenKey = 'webx11_ui_seen'; + } + + requirements() { return { met: true, missing: [] }; } + + getHelpInfo() { + return { + 'ui': 'ui [list|<mode>|next|picker|classic] — Switch interface: classic | easy | chat | studio | workspace | commander | cockpit' + }; + } + + registerCommands() { this.commands['ui'] = (args) => this.handle(args || []); } + + /* ---------- boot ---------- */ + init(terminal) { + this.terminal = terminal; + terminal.uiShell = this; // ext mode modules reach the shell through this + this._injectBaseCSS(); + this._root = this._ensureRoot(); + + // ?ui= is a REQUEST, not a preference: honoured only if that mode actually registers, so a + // shared link with a typo cannot poison localStorage or suppress onboarding. + let wanted = localStorage.getItem(this.key) || 'classic'; + try { + const q = new URLSearchParams(location.search).get('ui'); + // 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 && this.lazyIds().indexOf(q) !== -1) { wanted = q; this._fromQuery = true; } + } catch (e) { /* no URLSearchParams: ignore the request */ } + // 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 + // kept as a dead preference forever. + this._sweepT = setTimeout(() => this._sweepPending(), 6000); + + // First run: show the picker at once. Waiting for four registered modes was a dead condition — + // the six mode files are lazy now, so nothing registers before the user picks one, and every + // first-run visitor paid a 4.4 s delay for a list that was static anyway. + if (!localStorage.getItem(this.seenKey) && this.current === 'classic' && !this._pending) { + this._pickerT = setTimeout(() => this.showPicker(), 600); + } + } + + _sweepPending() { + if (!this._pending) return; + if (this._loading[this._pending]) return; // still in flight: the load's own callback decides + if (this.modes[this._pending]) return; // registered late: apply() already ran + if (this.terminal) { + this.terminal.print('Interface mode \'' + this._pending + '\' did not load — staying on ' + + this.current + '.', 'warning'); + } + this._pending = null; + localStorage.setItem(this.key, this.current); + } + + /* ---------- registry ---------- */ + registerMode(spec) { + if (!spec || !spec.id || typeof spec.mount !== 'function') { + console.warn('[UIShell] ignoring invalid mode spec', spec && spec.id); + return false; + } + this.modes[spec.id] = spec; + if (this._pending === spec.id) { + this._pending = null; + this.apply(spec.id, { silent: true, persist: false }); + } + return true; + } + + list() { return Object.keys(this.modes).map(id => this.modes[id]).sort((a, b) => a.level - b.level); } + + // The six modes exist even before their files are fetched, so `ui list` / `ui next` / the picker + // must not be limited to what happens to be registered. One list, not four copies. + lazyIds() { return ['easy', 'chat', 'studio', 'workspace', 'commander', 'cockpit']; } + + /* ---------- switching ---------- */ + 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(); } + // hasOwnProperty: `this.modes` is a plain object, so ?ui=constructor / __proto__ passed the + // truthiness check and reached the mount path without any mode registering. + if (want !== 'classic' && !Object.prototype.hasOwnProperty.call(this.modes, want)) { + // Lazy load. The six mode files are NOT in extKeys: a full eager boot issues ~40-55 + // requests and the per-IP `limit_req` bucket (then burst=50, now 200) 503'd everything + // after it — measured 2026-11-07. + if (o.retried) { + if (this.terminal) this.terminal.print(`UI mode '${want}' is not available.`, 'warning'); + return false; + } + 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) { + // classic is a REAL state, not "attribute absent": the probe, the CSS scoping and + // _syncChrome all key off data-ui-mode, so it must be set even when nothing changes. + document.body.dataset.uiMode = want; + document.body.classList.add('wx11-ui-' + want); + if (o.persist !== false) localStorage.setItem(this.key, want); + return true; + } + + 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); + + // _syncChrome on EVERY switch: it hides #quick-actions, which otherwise stays in the tab order + // and the accessibility tree behind the opaque mode root (found by review). + if (want === 'classic') { this._hideFooter(); this._syncChrome(); return true; } + + const api = this.api(); + let cleanup = null; + try { + cleanup = this.modes[want].mount(api); + } catch (e) { + console.error('[UIShell] mount failed for ' + want + ':', e); + this._abortToClassic(want, 'could not start (' + e.message + ')'); + return false; + } + this._cleanup = typeof cleanup === 'function' ? cleanup : null; + + // Identity, not just presence: a mode that appends its own <div id="terminal-container"> + // would pass a presence check while terminal.js keeps printing into the detached original. + // Identity for EVERY stock node the terminal bound, not just the output pane: terminal.js also + // holds #output, #command-input, #input-area and #status-bar, so a mode that clones one of those + // leaves the terminal writing into a detached node while the page looks fine. + const lost = (this._where || []).filter((w) => { + const live = document.getElementById(w.el.id); + return !live || live !== w.el || !document.body.contains(w.el); + }); + if (lost.length) { + this._abortToClassic(want, 'removed or replaced ' + lost.map((w) => '#' + w.el.id).join(', ')); + return false; + } + this._dockFooter(); + this._syncChrome(); + // 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; + } + + _abortToClassic(id, why) { + 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'; + document.body.classList.remove('wx11-ui-' + id); + document.body.dataset.uiMode = 'classic'; + this._hideFooter(); + this._cssSweep(); + this._syncChrome(); + } + + next() { + const order = ['classic'].concat(this.lazyIds()); + const i = order.indexOf(this.current); + return this.apply(order[(i + 1) % order.length]); + } + + // Fetch a mode on demand. Uses the same cache-busting version the loader uses, so a mode can + // never be served stale after a version bump. Public moduleManager methods only, and no + // printing: a mode load must not litter the chat. + async _loadMode(id) { + if (this._loading[id]) return this._loading[id]; // one load per id, however many taps + const p = this._loadModeOnce(id).finally(() => { delete this._loading[id]; }); + this._loading[id] = p; + return p; + } + + async _loadModeOnce(id) { + const mm = this.terminal && this.terminal.moduleManager; + if (!mm) return this._loadFail(id, 'no module manager'); + const ver = localStorage.getItem('webx11_cache_ver') || mm.LATEST_VER || '0'; + let pkg = null; + try { + // no-store: the loader already cached packages.json under the same versioned URL. + const idx = await (await fetch('packages.json?v=' + ver, { cache: 'no-store' })).json(); + // Mode ids are bare (`easy`); package keys are prefixed (`ui-easy`). Looking up only the + // bare id reported "no package entry" for every mode — measured 2026-11-07. + // Only `ui-<id>`: without this a crafted ?ui=calc fetches and initialises an unrelated ext + // module through the mode loader (same-origin, full page access). + pkg = idx && idx.packages && idx.packages['ui-' + id]; + } catch (e) { + return this._loadFail(id, 'packages.json unreadable: ' + e.message); + } + if (!pkg || !pkg.url) return this._loadFail(id, 'no package entry (ver=' + ver + ')'); + const url = pkg.url + (pkg.url.indexOf('?') === -1 ? '?v=' : '&v=') + ver; + // Reuse the SAME loader every ext module uses (fetch → createModuleClass → registerModule → + // init). A bespoke fetch/instantiate path here drifted from it twice and cost a debug cycle. + // Two attempts: a 503 from nginx `limit_req` is transient, and a mode that silently fails to + // load looks exactly like a broken button. + let ok = await mm.loadModuleFromURL(url); + if (!ok) { + await new Promise(r => setTimeout(r, 1200)); // >= 1 token at 60 r/m before retrying + ok = await mm.loadModuleFromURL(url); + } + if (!ok) return this._loadFail(id, 'loader refused ' + url); + if (!this.modes[id]) return this._loadFail(id, 'loaded but registered no mode'); + return true; + } + + _loadFail(id, why) { + console.warn('[UIShell] could not load mode ' + id + ': ' + why); + if (this.terminal && this.terminal.print) { + this.terminal.print('Interface mode ' + id + ' could not be loaded (' + why + ').', 'warning'); + } + return false; + } + + _teardown() { + if (this._cleanup) { try { this._cleanup(); } catch (e) { console.warn('[UIShell] unmount:', e.message); } } + this._cleanup = null; + // Restore first: the mode's subtree may still hold the stock nodes, and any + // getElementById() between these two lines would see nothing. The load-bearing part is that + // the restore RUNS — dropping it is what A5 catches (reordering alone still passes). + this._restoreStock(); + if (this._root) this._root.innerHTML = ''; + this._cssSweep(); + document.body.classList.remove('wx11-ui-' + this.current); + } + + /* ---------- stock DOM bookkeeping ---------- */ + _stockEls() { + return ['terminal-container', 'input-area', 'quick-actions', 'status-bar'] + .map(id => document.getElementById(id)).filter(Boolean); + } + + _snapshotStock() { + if (this._where) return; + this._terminalEl = document.getElementById('terminal-container'); + this._where = this._stockEls().map(el => ({ + el, + parent: el.parentNode, + index: Array.prototype.indexOf.call(el.parentNode.children, el), + next: el.nextSibling || null, // trailing anchor: what followed the last stock node + })); + } + + _restoreStock() { + if (!this._where) return; + // 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 + // Anchor on the next stock node already home; for the LAST stock node fall back to the + // sibling that followed it originally, or the group lands after every trailing body child. + const anchor = list.find((c) => c.index > w.index && c.el.parentNode === w.parent) + || (w.next && w.next.parentNode === w.parent ? w.next : null); + try { w.parent.insertBefore(w.el, anchor ? anchor.el : null); } catch (e) { /* detached */ } + }); + } + + /* ---------- chrome ---------- */ + _syncChrome() { + const qa = document.getElementById('quick-actions'); + if (qa) qa.style.display = this.current === 'classic' ? '' : 'none'; + } + + _ensureRoot() { + let root = document.getElementById('ui-root'); + if (!root) { + root = document.createElement('div'); + root.id = 'ui-root'; + root.appendChild(this._ensureFoot()); + document.body.appendChild(root); + } + return root; + } + + // Every mode gets the status bar (network state + the AI "stop" button) whether it asks for it + // or not. A mode that forgets it would strand a streaming answer with no way to stop. + _ensureFoot() { + let foot = document.getElementById('ui-foot'); + if (!foot) { + foot = document.createElement('div'); + foot.id = 'ui-foot'; + } + return foot; + } + + _dockFooter() { + const foot = this._ensureFoot(); + const bar = document.getElementById('status-bar'); + if (bar && bar.parentNode !== foot) foot.appendChild(bar); + if (this._root && foot.parentNode !== this._root) this._root.appendChild(foot); + foot.style.display = ''; + } + + _hideFooter() { + const foot = document.getElementById('ui-foot'); + if (foot) foot.style.display = 'none'; + } + + _injectBaseCSS() { + if (document.getElementById('wx11-ui-base-css')) return; + const st = document.createElement('style'); + st.id = 'wx11-ui-base-css'; + st.textContent = [ + // clip, not hidden: `overflow:hidden` still creates a scroll container, and the terminal's + // scrollIntoView() on new output scrolled #ui-root by 464px — the title, the action cards + // and the composer all left the screen (measured 2026-11-07). `clip` cannot be scrolled. + '#ui-root{position:fixed;inset:0;display:none;overflow:hidden;overflow:clip;z-index:40;background:var(--bg-color);color:var(--text-color)}', + // flex column, not block: a block container makes each child's `flex:1 1 auto` inert and the + // terminal collapsed to 0px. Specificity is deliberately (1,0,0) so a mode that wants a GRID + // wins — with the old `body[data-ui-mode]:not(...) #ui-root` (1,2,1) every mode's + // `display:grid` was beaten and commander/studio/cockpit laid out as a plain stack + // (commander on a phone: two rails hidden, 253px of content in an 844px black page). + '#ui-root{display:flex;flex-direction:column;height:100%}', + 'body:not([data-ui-mode]) #ui-root,body[data-ui-mode="classic"] #ui-root{display:none!important}', + '#ui-root > *{min-width:0;min-height:0}', + // A non-classic mode owns the viewport: the page itself must not scroll, or terminal + // output drags the action cards off-screen and the composer never comes back. + 'body[data-ui-mode]:not([data-ui-mode="classic"]){overflow:hidden;overflow:clip}', + 'body[data-ui-mode]:not([data-ui-mode="classic"]) #ui-root{overflow:clip}', + 'body[data-ui-mode]:not([data-ui-mode="classic"]) #cmd-cards{display:none!important}', + // A mode IS the onboarding: six cards behind the classic "Let's Get Started" modal is two + // competing first-run prompts, and the modal covered the easy mode's action cards. + 'body[data-ui-mode]:not([data-ui-mode="classic"]) #welcome-modal{display:none!important}', + 'body[data-ui-mode]:not([data-ui-mode="classic"]) #ui-root{padding-bottom:34px}', + // The stock containers are flex children of <body>; inside a pane they must be able to + // shrink, or every mode overflows vertically and the input scrolls off-screen. + // basis 0, not auto: with `auto` the terminal takes its 4 600px content height and pushes + // every sibling out of the viewport instead of scrolling inside its own pane. + // min-height 100px, not 0: in the tabbed/panel modes the terminal sits inside a grid cell + // that is not a flex parent, so `flex:1 1 0` resolves to 0 and the output strip vanished. + '#ui-root #terminal-container{flex:1 1 0;min-height:100px;height:0;overflow-y:auto;padding:0}', + '#ui-root #input-area{flex:0 0 auto}', + '#ui-root #status-bar{flex:0 0 auto}', + '#ui-foot{position:absolute;left:0;right:0;bottom:0;height:34px;display:flex;align-items:center;', + 'gap:8px;padding:0 10px;background:var(--bg-secondary);border-top:1px solid var(--border-color);', + 'font-size:.78em;z-index:5}', + '#ui-foot #status-bar{border:0;background:transparent;flex:1 1 auto;display:flex;', + 'align-items:center;justify-content:space-between;gap:8px;font-size:1em;padding:0}', + '.wx11-pane{display:flex;flex-direction:column;min-height:0;min-width:0;overflow:hidden}', + '.wx11-card{background:var(--bg-secondary);border:1px solid var(--border-color);border-radius:10px;padding:12px}', + '.wx11-btn{min-height:44px;padding:10px 14px;background:var(--bg-tertiary);color:var(--text-color);', + 'border:1px solid var(--border-color);border-radius:8px;font:inherit;cursor:pointer;text-align:left}', + '.wx11-btn:active{background:var(--accent-color);color:#000}', + '.wx11-chip{display:inline-block;min-height:40px;padding:8px 12px;margin:0 6px 6px 0;border-radius:999px;', + 'border:1px solid var(--border-color);background:var(--bg-tertiary);font-size:.85em;cursor:pointer}', + '.wx11-dim{color:var(--text-muted);font-size:.85em}', + '.wx11-pre{margin:0;white-space:pre-wrap;word-break:break-word;font-size:.8em;line-height:1.35;', + 'max-height:100%;overflow:auto;color:var(--text-color)}', + '@media (max-width:600px){.wx11-btn{min-height:48px}}' + ].join('\n'); + document.head.appendChild(st); + } + + injectCSS(id, css) { + this.removeCSS(id); + const st = document.createElement('style'); + st.id = 'wx11-ui-css-' + id; + st.textContent = css; + document.head.appendChild(st); + this._cssIds.push(id); + } + + removeCSS(id) { + const el = document.getElementById('wx11-ui-css-' + id); + if (el) el.remove(); + this._cssIds = this._cssIds.filter(x => x !== id); + } + + _cssSweep() { this._cssIds.slice().forEach(id => this.removeCSS(id)); } + + /* ---------- capability API handed to modes ---------- */ + api() { + const self = this; + const term = this.terminal; + this._snapshotStock(); + return { + root: this._root, + terminal: term, + stock: { + output: document.getElementById('terminal-container'), + input: document.getElementById('input-area'), + quickActions: document.getElementById('quick-actions'), + statusBar: document.getElementById('status-bar') + }, + // user text → executeCommand (security gate applies) + exec: (line) => term.executeCommand(String(line)), + // shell-authored fixed strings only → capturePipeOutput (no security gate) + capture: (line) => { + // 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(); + // 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(''); + // Commands with UI side effects are not capturable: dispatchCommand() runs them for + // real, so capturing `modules` re-opened the module-manager panel on every refresh + // tick ("the modules tab keeps opening", reported 2026-11-07). + const UI_ONLY = ['modules', 'config', 'edit', 'course', 'dashboard', 'ui', 'clear', + 'expand', 'sysmsg', 'font-size', 'setfont', 'startup-modules', 'load-local']; + if (UI_ONLY.indexOf(cmd) !== -1) { + return Promise.resolve('(' + cmd + ' opens a panel — not capturable)'); + } + return term.capturePipeOutput(cmd, parts.slice(1), raw); + }, + fs: term.fs, + el: (tag, props, kids) => { + const node = document.createElement(tag); + if (props) for (const k of Object.keys(props)) { + if (k === 'text') node.textContent = props[k]; + // No `html` sink: zero modes needed it, and it was the only innerHTML entry point in + // the capability API. Dynamic text goes through textContent. + else if (k === 'class') node.className = props[k]; + else node.setAttribute(k, props[k]); + } + (kids || []).forEach(k => node.appendChild(k)); + return node; + }, + attach: (slot, el) => { if (slot && el) slot.appendChild(el); return el; }, + injectCSS: (id, css) => self.injectCSS(id, css), + removeCSS: (id) => self.removeCSS(id), + interval: (fn, ms) => { + const mm = term.moduleManager; + return mm && mm._setInterval ? mm._setInterval(fn, ms) : setInterval(fn, ms); + }, + clearInterval: (id) => { + const mm = term.moduleManager; + if (mm && mm._clearTimer) mm._clearTimer(id); else clearInterval(id); + }, + toast: (msg, ms) => self.toast(msg, ms) + }; + } + + toast(msg, ms) { + let host = document.getElementById('wx11-ui-toast'); + if (!host) { + host = document.createElement('div'); + host.id = 'wx11-ui-toast'; + host.style.cssText = 'position:fixed;left:50%;transform:translateX(-50%);bottom:44px;' + + 'background:var(--bg-secondary);border:1px solid var(--accent-color);color:var(--text-color);' + + 'padding:10px 14px;border-radius:8px;z-index:9999;font-size:.9em;max-width:90vw;'; + document.body.appendChild(host); + } + host.textContent = String(msg); + host.style.display = 'block'; + clearTimeout(this._toastT); + this._toastT = setTimeout(() => { host.style.display = 'none'; }, ms || 2600); + } + + /* ---------- picker ---------- */ + showPicker() { + if (this._picker) return this._picker; + 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 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'); + // ALWAYS the six, in level order. `this.list()` only holds modes that have already been + // fetched, so switching on it showed 4 cards once the user had visited 4 modes and silently + // dropped commander/cockpit (reported 2026-11-07). Registered specs win for label/tagline. + const modes = [ + { id: 'easy', label: 'Easy', level: 1, icon: '🌱', tagline: 'Just ask. No commands.' }, + { id: 'chat', label: 'Chat', level: 2, icon: '💬', tagline: 'Conversation first.' }, + { id: 'studio', label: 'Studio', level: 3, icon: '🎨', tagline: 'Chat plus a place for your files.' }, + { id: 'workspace', label: 'Workspace', level: 4, icon: '🗂', tagline: 'Everything at a glance.' }, + { id: 'commander', label: 'Commander', level: 5, icon: '⌨️', tagline: 'Terminal with rails.' }, + { id: 'cockpit', label: 'Cockpit', level: 6, icon: '🛰', tagline: 'Live system telemetry.' } + ].map((m) => this.modes[m.id] || m); + modes.forEach(m => { + const b = document.createElement('button'); + b.className = 'wx11-btn'; + b.style.cssText = 'display:flex;gap:12px;align-items:center;width:100%'; + const ico = document.createElement('span'); + ico.style.fontSize = '1.5em'; + ico.textContent = m.icon || '▫'; // never innerHTML: labels can change + const txt = document.createElement('span'); + const strong = document.createElement('b'); + strong.textContent = String(m.label); + const tag = document.createElement('span'); + tag.className = 'wx11-dim'; + tag.textContent = String(m.tagline || ''); + txt.appendChild(strong); txt.appendChild(document.createElement('br')); txt.appendChild(tag); + b.appendChild(ico); b.appendChild(txt); + b.addEventListener('click', () => { self._closePicker(); self.apply(m.id); }); + list.appendChild(b); + }); + wrap.querySelector('#wx11-ui-picker-skip').addEventListener('click', () => { + self._closePicker(); self.apply('classic'); + }); + const onEsc = (ev) => { if (ev.key === 'Escape') { self._closePicker(); } }; + document.addEventListener('keydown', onEsc); + wrap._onEsc = onEsc; + this._picker = wrap; + return wrap; + } + + _closePicker() { + if (this._pickerT) { clearTimeout(this._pickerT); this._pickerT = null; } + if (this._picker) { + if (this._picker._onEsc) document.removeEventListener('keydown', this._picker._onEsc); + this._picker.remove(); + this._picker = null; + } + localStorage.setItem(this.seenKey, '1'); + } + + /* ---------- command ---------- */ + handle(args) { + const sub = String(args[0] || '').toLowerCase(); + const available = ['classic'].concat(this.list().map(m => m.id)); + if (!sub || sub === 'list') { + this.terminal.print('Interface modes:', 'info'); + this.terminal.print(' classic (level 0) — the terminal you already know', 'info'); + this.list().forEach(m => this.terminal.print( + ' ' + String(m.id).padEnd(10) + '(level ' + m.level + ') — ' + (m.tagline || m.label), 'info')); + this.terminal.print('Usage: ui <mode> | ui next | ui picker | ui classic', 'info'); + return; + } + if (sub === 'picker') return void this.showPicker(); + if (sub === 'next') return void this.next(); + // A mode that is not registered yet is not an error: it is lazy-loaded on demand. Only an + // id that is not one of the six is unknown. + const LAZY = this.lazyIds(); + if (available.indexOf(sub) === -1 && LAZY.indexOf(sub) === -1) { + this.terminal.print(`Unknown mode '${sub}'. Available: ${available.join(', ')}`, 'warning'); + return; + } + if (this.apply(sub)) this.terminal.print('Interface: ' + sub, 'success'); + } +} |