8e70875631
Re-skin the mobile toolbar to Google Workspace dark vocabulary (#202124 bar, #303134 surface, #3c4043 hairlines, Roboto 12/500) keeping #D35400 as the sethmux accent. Adds .mono / .hi / .grn button variants with hover/active wash transitions. Adds a compose bar (toggled by 'Type' button) — a real <input> with autocorrect=on and enterkeyhint=send. Enter/Send/↵ flushes the assembled string to stdin in one shot, plus \\r. Other toolbar taps while composing flush typed text first to preserve order, but keep the compose bar open. Sidesteps xterm.js's per-keystroke input model that drops Gboard/iOS bulk-replacement events. relayout() now measures bar.offsetHeight instead of hard-coding calc(100vh - 88px) so the third row reflows the terminal correctly. Records the design rationale and what was rejected in DECISIONS.md.
306 lines
11 KiB
JavaScript
306 lines
11 KiB
JavaScript
(function(){
|
|
if(window._toolbar) return;
|
|
window._toolbar=true;
|
|
|
|
// ── Sethmux toolbar — Google Workspace dark vibe, sethmux orange accent ──
|
|
// Tokens (kept local, not :root, so we don't pollute the host page)
|
|
// bg #202124 toolbar surface
|
|
// surface #303134 button face
|
|
// border #3c4043 hairlines
|
|
// text #e8eaed primary
|
|
// text-2 #9aa0a6 secondary / icons at rest
|
|
// accent #D35400 sethmux orange (replaces Google blue)
|
|
// accent-bg #3a2a1a tinted hover/selected wash
|
|
// ok #81c995 save success
|
|
|
|
var css=document.createElement('style');
|
|
css.textContent = [
|
|
"#mb{",
|
|
"display:none;position:fixed;bottom:0;left:0;right:0;",
|
|
"background:#202124;",
|
|
"border-top:1px solid #3c4043;",
|
|
"padding:6px 8px 7px;",
|
|
"gap:0;flex-direction:column;align-items:stretch;",
|
|
"z-index:99999;",
|
|
"font-family:'Roboto','Helvetica Neue',Arial,sans-serif;",
|
|
"-webkit-font-smoothing:antialiased;",
|
|
"box-shadow:0 -1px 0 rgba(0,0,0,.4),0 -8px 24px rgba(0,0,0,.35);",
|
|
"}",
|
|
|
|
"#mb .row{",
|
|
"display:flex;gap:4px;justify-content:center;align-items:center;",
|
|
"width:100%;",
|
|
"}",
|
|
"#mb .row + .row{margin-top:4px}",
|
|
|
|
"#mb button{",
|
|
"background:#303134;",
|
|
"color:#e8eaed;",
|
|
"border:1px solid #3c4043;",
|
|
"border-radius:4px;",
|
|
"padding:0 10px;height:32px;min-width:40px;",
|
|
"font:500 12px/1 'Roboto','Helvetica Neue',Arial,sans-serif;",
|
|
"letter-spacing:.1px;",
|
|
"cursor:pointer;",
|
|
"touch-action:manipulation;",
|
|
"-webkit-tap-highlight-color:transparent;",
|
|
"user-select:none;",
|
|
"display:inline-flex;align-items:center;justify-content:center;",
|
|
"transition:background .15s ease,border-color .15s ease,color .15s ease;",
|
|
"}",
|
|
"#mb button:hover{background:#3a2a1a;border-color:#3c4043;color:#fff}",
|
|
"#mb button:active{background:#D35400;border-color:#D35400;color:#0a0a0a}",
|
|
|
|
// Mono labels for chord/arrow keys so they read as terminal input
|
|
"#mb button.mono{",
|
|
"font-family:'Roboto Mono','SF Mono',ui-monospace,Menlo,Consolas,monospace;",
|
|
"font-weight:400;color:#9aa0a6;",
|
|
"}",
|
|
"#mb button.mono:hover{color:#e8eaed}",
|
|
|
|
// Accent (orange) — used for primary/important actions at rest
|
|
"#mb button.hi{",
|
|
"color:#f0a36b;border-color:#5a3a22;background:#2a1f15;",
|
|
"}",
|
|
"#mb button.hi:hover{background:#3a2a1a;color:#ffb37a;border-color:#7a4a2a}",
|
|
|
|
// Toggled-on state (Sel active, etc.) — filled accent
|
|
"#mb button.on{",
|
|
"background:#D35400;border-color:#D35400;color:#0a0a0a;",
|
|
"}",
|
|
"#mb button.on:hover{background:#e26416;border-color:#e26416;color:#0a0a0a}",
|
|
|
|
// Success (Save) — Google green at rest, fills on confirm
|
|
"#mb button.grn{color:#81c995;border-color:#3c4043;background:#303134}",
|
|
"#mb button.grn:hover{background:#1f2a22;color:#a8e0b8;border-color:#3a5a44}",
|
|
"#mb button.grn.on{background:#1e8e3e;border-color:#1e8e3e;color:#0a0a0a}",
|
|
|
|
// Vertical hairline divider between groups
|
|
"#mb .sep{",
|
|
"width:1px;height:20px;background:#3c4043;margin:0 4px;flex-shrink:0;",
|
|
"}",
|
|
|
|
// ── Compose bar (mobile autocorrect workaround) ──
|
|
"#mb .compose{",
|
|
"display:none;width:100%;gap:4px;align-items:center;margin-top:4px;",
|
|
"}",
|
|
"#mb.composing .compose{display:flex}",
|
|
"#mb.composing #typebtn{",
|
|
"background:#D35400;border-color:#D35400;color:#0a0a0a;",
|
|
"}",
|
|
"#mb-compose{",
|
|
"flex:1;min-width:0;height:36px;",
|
|
"padding:0 10px;",
|
|
"background:#303134;color:#e8eaed;",
|
|
"border:1px solid #3c4043;border-radius:4px;",
|
|
"font:400 14px/1 'Roboto Mono','SF Mono',ui-monospace,Menlo,Consolas,monospace;",
|
|
"outline:none;",
|
|
"-webkit-appearance:none;appearance:none;",
|
|
"caret-color:#D35400;",
|
|
"}",
|
|
"#mb-compose:focus{border-color:#D35400}",
|
|
"#mb-compose::placeholder{color:#5f6368}",
|
|
"#mb .send{",
|
|
"height:36px;min-width:54px;padding:0 12px;",
|
|
"background:#D35400;border:1px solid #D35400;color:#0a0a0a;",
|
|
"border-radius:4px;font:500 12px/1 'Roboto',sans-serif;cursor:pointer;",
|
|
"}",
|
|
"#mb .send:disabled{background:#303134;border-color:#3c4043;color:#5f6368;cursor:default}",
|
|
"#mb .send.nl{background:#303134;border-color:#3c4043;color:#9aa0a6;min-width:38px;padding:0 8px}",
|
|
"#mb .send.nl:hover{color:#e8eaed;background:#3a2a1a}",
|
|
|
|
// Selection mode visual — dim the terminal slightly so the text-select layer reads
|
|
"body.selmode .xterm-screen{",
|
|
"pointer-events:none!important;",
|
|
"user-select:text!important;-webkit-user-select:text!important;",
|
|
"}",
|
|
"body.selmode .xterm{filter:brightness(.92)}",
|
|
|
|
"@media(max-width:900px){#mb{display:flex}}",
|
|
// Tighter on very narrow phones
|
|
"@media(max-width:380px){",
|
|
"#mb button{padding:0 7px;min-width:34px;height:30px;font-size:11.5px}",
|
|
"#mb .sep{margin:0 2px}",
|
|
"}",
|
|
].join("");
|
|
document.head.appendChild(css);
|
|
|
|
var bar=document.createElement('div');
|
|
bar.id='mb';
|
|
bar.innerHTML =
|
|
'<div class="row">' +
|
|
'<button class="hi" data-k="\x01c">+ Tab</button>' +
|
|
'<button data-k="\x01n">Next</button>' +
|
|
'<button data-k="\x01p">Prev</button>' +
|
|
'<div class="sep"></div>' +
|
|
'<button class="mono" data-k="\x03">^C</button>' +
|
|
'<button class="mono" data-k="\x04">^D</button>' +
|
|
'<button data-k="\x0c">Clr</button>' +
|
|
'<div class="sep"></div>' +
|
|
'<button class="mono" data-k="\x1b">Esc</button>' +
|
|
'<button class="mono" data-k="\t">Tab</button>' +
|
|
'<button class="mono" data-k="\x1bOA">\u25B2</button>' +
|
|
'<button class="mono" data-k="\x1bOB">\u25BC</button>' +
|
|
'</div>' +
|
|
'<div class="row">' +
|
|
'<button class="hi" id="selbtn" data-sel="1">Sel</button>' +
|
|
'<button class="hi" id="pastebtn" data-paste="1">Paste</button>' +
|
|
'<button data-k="\x01z">Zoom</button>' +
|
|
'<button class="grn" data-save="1">Save</button>' +
|
|
'<div class="sep"></div>' +
|
|
'<button data-k="\x01v">V.Spl</button>' +
|
|
'<button data-k="\x01s">H.Spl</button>' +
|
|
'<button data-k="\x01o">Pane</button>' +
|
|
'<button data-k="\x01x">Kill</button>' +
|
|
'<div class="sep"></div>' +
|
|
'<button class="hi" id="typebtn" data-type="1">Type</button>' +
|
|
'</div>' +
|
|
'<div class="compose row">' +
|
|
'<input id="mb-compose" type="text" ' +
|
|
'placeholder="type here \u2014 autocorrect on, \u21B5 sends" ' +
|
|
'autocomplete="on" autocorrect="on" autocapitalize="sentences" ' +
|
|
'spellcheck="true" enterkeyhint="send" inputmode="text" />' +
|
|
'<button class="send nl" data-nl="1" title="Send newline">\u21B5</button>' +
|
|
'<button class="send" data-send="1">Send</button>' +
|
|
'</div>';
|
|
document.body.appendChild(bar);
|
|
|
|
// ── terminal I/O ─────────────────────────────────────────
|
|
function send(k){
|
|
if(document.body.classList.contains('selmode')) toggleSel();
|
|
var t=window.term;
|
|
if(!t) return;
|
|
if(t._core && t._core.coreService && t._core.coreService.triggerDataEvent){
|
|
t._core.coreService.triggerDataEvent(k);
|
|
} else if(t._core && t._core._onData){
|
|
t._core._onData.fire(k);
|
|
} else if(t.input){
|
|
t.input(k);
|
|
}
|
|
t.focus();
|
|
}
|
|
|
|
function toggleSel(){
|
|
var b=document.getElementById('selbtn');
|
|
document.body.classList.toggle('selmode');
|
|
if(document.body.classList.contains('selmode')){
|
|
b.classList.add('on');b.textContent='Done';
|
|
} else {
|
|
b.classList.remove('on');b.textContent='Sel';
|
|
window.getSelection().removeAllRanges();
|
|
if(window.term) window.term.focus();
|
|
}
|
|
}
|
|
|
|
function doPaste(){
|
|
if(!navigator.clipboard||!navigator.clipboard.readText){
|
|
alert('Clipboard access not available (needs HTTPS)');
|
|
return;
|
|
}
|
|
navigator.clipboard.readText().then(function(text){
|
|
if(text) send(text);
|
|
}).catch(function(e){
|
|
alert('Clipboard read failed: '+e.message);
|
|
});
|
|
}
|
|
|
|
function doSave(){
|
|
send('\x01S');
|
|
var btn=document.querySelector('[data-save]');
|
|
btn.classList.add('on');
|
|
btn.textContent='\u2713 Saved';
|
|
setTimeout(function(){btn.classList.remove('on');btn.textContent='Save';},1500);
|
|
}
|
|
|
|
// ── Compose bar (mobile autocorrect workaround) ──
|
|
// xterm.js reads keys from a hidden textarea via per-keystroke events;
|
|
// Gboard autocorrect/swipe replaces .value in bulk so those chars never
|
|
// reach stdin. The compose bar gives autocorrect a real <input> to chew
|
|
// on, then sends the assembled string to the terminal in one shot.
|
|
var ci=document.getElementById('mb-compose');
|
|
|
|
function toggleType(){
|
|
var on=bar.classList.toggle('composing');
|
|
if(on){
|
|
// Defer focus so the keyboard opens reliably on iOS/Android
|
|
setTimeout(function(){ ci.focus(); },0);
|
|
relayout();
|
|
} else {
|
|
ci.blur();
|
|
if(window.term) window.term.focus();
|
|
relayout();
|
|
}
|
|
}
|
|
|
|
function flushCompose(appendNewline){
|
|
var v=ci.value;
|
|
ci.value='';
|
|
if(v) send(v);
|
|
if(appendNewline) send('\r');
|
|
ci.focus();
|
|
}
|
|
|
|
ci.addEventListener('keydown',function(e){
|
|
if(e.key==='Enter'){
|
|
e.preventDefault();
|
|
flushCompose(true);
|
|
}
|
|
});
|
|
|
|
bar.addEventListener('click',function(e){
|
|
var btn=e.target.closest('button');
|
|
if(!btn) return;
|
|
if(btn.dataset.sel) return toggleSel();
|
|
if(btn.dataset.paste) return doPaste();
|
|
if(btn.dataset.save) return doSave();
|
|
if(btn.dataset.type) return toggleType();
|
|
if(btn.dataset.send) return flushCompose(true);
|
|
if(btn.dataset.nl) return flushCompose(true); // explicit newline button
|
|
if(btn.dataset.k) {
|
|
// Key pressed while composing: flush typed text first so order is preserved,
|
|
// but DON'T close the compose bar — user is mid-thought.
|
|
if(bar.classList.contains('composing') && ci.value){
|
|
var v=ci.value; ci.value=''; send(v);
|
|
}
|
|
send(btn.dataset.k);
|
|
if(bar.classList.contains('composing')) ci.focus();
|
|
}
|
|
});
|
|
|
|
// Keep terminal focused when tapping outside the toolbar
|
|
document.addEventListener('click', function(e){
|
|
if(!e.target.closest('#mb') && window.term) window.term.focus();
|
|
});
|
|
|
|
// Tab key shouldn't escape into toolbar focus
|
|
bar.querySelectorAll('button').forEach(function(b){ b.tabIndex = -1; });
|
|
|
|
// Disable mobile autocomplete on xterm's hidden textarea
|
|
function disableMobileAutocomplete(){
|
|
var ta=document.querySelector('.xterm-helper-textarea');
|
|
if(!ta || ta.dataset.acOff) return;
|
|
ta.setAttribute('autocomplete','off');
|
|
ta.setAttribute('autocorrect','off');
|
|
ta.setAttribute('autocapitalize','off');
|
|
ta.setAttribute('spellcheck','false');
|
|
ta.dataset.acOff='1';
|
|
}
|
|
|
|
// Resize terminal to leave room for the toolbar on mobile.
|
|
// Height is dynamic: 2 rows when collapsed, 3 when compose bar is open.
|
|
function relayout(){
|
|
var el=document.querySelector('.xterm');
|
|
if(!el || window.innerWidth>900) return;
|
|
var h=bar.offsetHeight || 92;
|
|
el.style.height='calc(100vh - '+(h+4)+'px)';
|
|
if(window.term && window.term.fit) window.term.fit();
|
|
}
|
|
|
|
var obs=new MutationObserver(function(){
|
|
disableMobileAutocomplete();
|
|
relayout();
|
|
});
|
|
obs.observe(document.body,{childList:true,subtree:true});
|
|
window.addEventListener('resize',relayout);
|
|
})();
|