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
+21
View File
@@ -0,0 +1,21 @@
# sethmux — Decision Log
Project-local decisions specific to sethmux. Cross-cutting infra decisions live in `~/bin/DECISIONS.md`.
Format: `YYYY-MM-DD: <decision> — <why>`
---
## 2026-04-24
- **Mobile compose bar instead of patching xterm.js IME handling.** Added a third toolbar row (toggled by `Type` button) holding a real `<input>` with `autocorrect=on`/`enterkeyhint=send`. Enter/Send flushes the assembled string to stdin in one shot. **Why:** xterm.js reads from a hidden textarea via per-keystroke events, but Gboard/iOS autocorrect, swipe, and predictions mutate `.value` in bulk via `inputType="insertReplacementText"` events that xterm.js discards. Bridging that into xterm would mean forking; the compose bar sidesteps it entirely with a one-shot string send. The existing `disableMobileAutocomplete()` on `.xterm-helper-textarea` is preserved as belt-and-suspenders for chord/arrow keys typed outside the compose bar.
- **Visual system: Google Workspace dark vocabulary, sethmux orange accent.** Tokens: bar `#202124`, button surface `#303134`, hairline `#3c4043`, primary text `#e8eaed`, accent `#D35400` (replaces Google blue), Roboto 12/500 + Roboto Mono 12/400 for chord keys. **Why:** the previous palette (`#111`/`#222`/2px orange top border) read as a generic terminal toolbar; the Workspace vocabulary makes the bar feel like a deliberate productivity surface while keeping `#D35400` as sethmux brand identity.
- **Manual deploy: `cp static/* /opt/sethmux/`.** No rsync, no auto-reload. **Why:** static assets only, served directly by Caddy from `/opt/sethmux/`. No daemon restart needed — ttyd doesn't serve `toolbar.js` (only `--index /opt/sethmux/index.html`); the rest is Caddy's `file_server` handler. Keep deploy explicit so we never ship in-progress edits.
## Deferred / Rejected
- **Forking xterm.js to handle `inputType: insertReplacementText` events.** Considered as the "real" fix for mobile autocorrect. Rejected because compose-bar approach is smaller, preserves upstream xterm.js, and is more obviously correct (one-shot string send vs. interleaved IME state machine).
- **`inputmode="none"` on the helper textarea ("nuclear option" from `AUTOCOMPLETE_FIX.md`).** Considered if mobile keyboard autocorrect still corrupts xterm input despite hardening. Rejected for now because it would fully disable the soft keyboard, requiring a separate toggle UX. The compose bar gives users the autocorrect surface they actually want without removing per-keystroke typing.
+209 -35
View File
@@ -2,26 +2,127 @@
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');
@@ -32,14 +133,14 @@
'<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 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 data-k="\x1b">Esc</button>'+
'<button data-k="\t">Tab</button>'+
'<button data-k="\x1bOA">\u25B2</button>'+
'<button data-k="\x1bOB">\u25BC</button>'+
'<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>' +
@@ -51,15 +152,24 @@
'<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(!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);
})();