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.
This commit is contained in:
Mortdecai
2026-04-24 19:50:17 -04:00
parent 451a055757
commit 8e70875631
2 changed files with 251 additions and 56 deletions
+230 -56
View File
@@ -2,64 +2,174 @@
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:#111;
border-top:2px solid #D35400;padding:4px 3px;gap:3px;justify-content:center;
flex-wrap:wrap;z-index:99999}
#mb button{background:#222;color:#ccc;border:1px solid #444;border-radius:5px;
padding:9px 10px;font-size:13px;font-family:ui-monospace,monospace;
cursor:pointer;touch-action:manipulation;-webkit-tap-highlight-color:transparent;
min-width:38px;text-align:center;user-select:none}
#mb button:active{background:#D35400;color:#0a0a0a;border-color:#D35400}
#mb button.hi{border-color:#D35400;color:#D35400}
#mb button.on{background:#D35400;color:#0a0a0a;border-color:#D35400}
#mb button.grn{border-color:#4e9a06;color:#4e9a06}
#mb button.grn.on{background:#4e9a06;color:#0a0a0a}
#mb .sep{width:1px;background:#333;margin:0 1px;align-self:stretch}
#mb .row{display:flex;gap:3px;justify-content:center;width:100%}
@media(max-width:900px){#mb{display:flex}}
body.selmode .xterm-screen{pointer-events:none!important;
user-select:text!important;-webkit-user-select:text!important}
`;
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 data-k="\x03">^C</button>'+
'<button data-k="\x04">^D</button>'+
'<button data-k="\x0c">Clr</button>'+
'<div class="sep"></div>'+
'<button data-k="\x1b">Esc</button>'+
'<button data-k="\t">Tab</button>'+
'<button data-k="\x1bOA">\u25B2</button>'+
'<button 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>'+
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);
// Send data to terminal via xterm's _core (bypasses any .input() issues)
// ── terminal I/O ─────────────────────────────────────────
function send(k){
if(document.body.classList.contains('selmode')) toggleSel();
var t=window.term;
if(!t) return;
// Try _core.triggerDataEvent first (fires onData which ttyd hooks)
if(t._core && t._core.coreService && t._core.coreService.triggerDataEvent){
t._core.coreService.triggerDataEvent(k);
} else if(t._core && t._core._onData){
@@ -98,34 +208,98 @@
send('\x01S');
var btn=document.querySelector('[data-save]');
btn.classList.add('on');
btn.textContent='\u2713';
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.k) send(btn.dataset.k);
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();
}
});
// Ensure terminal keeps focus (prevents Tab from escaping to browser chrome)
// Keep terminal focused when tapping outside the toolbar
document.addEventListener('click', function(e){
if(!e.target.closest('#mb') && window.term) window.term.focus();
});
// Remove tabindex from toolbar buttons so Tab can't escape to them
// Tab key shouldn't escape into toolbar focus
bar.querySelectorAll('button').forEach(function(b){ b.tabIndex = -1; });
// Shrink terminal for toolbar on mobile (2 rows)
var obs=new MutationObserver(function(){
// 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){
el.style.height='calc(100vh - 88px)';
if(window.term && window.term.fit) window.term.fit();
}
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);
})();