Files
sethmux/static/toolbar.js
T
Mortdecai 8e70875631 feat: Workspace dark refresh + mobile compose bar
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.
2026-04-24 19:50:17 -04:00

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);
})();