diff --git a/DECISIONS.md b/DECISIONS.md new file mode 100644 index 0000000..2815cd2 --- /dev/null +++ b/DECISIONS.md @@ -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: ` + +--- + +## 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 `` 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. diff --git a/static/toolbar.js b/static/toolbar.js index 24bfe98..b201279 100644 --- a/static/toolbar.js +++ b/static/toolbar.js @@ -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= - '
'+ - ''+ - ''+ - ''+ - '
'+ - ''+ - ''+ - ''+ - '
'+ - ''+ - ''+ - ''+ - ''+ - '
'+ - '
'+ - ''+ - ''+ - ''+ - ''+ - '
'+ - ''+ - ''+ - ''+ - ''+ + bar.innerHTML = + '
' + + '' + + '' + + '' + + '
' + + '' + + '' + + '' + + '
' + + '' + + '' + + '' + + '' + + '
' + + '
' + + '' + + '' + + '' + + '' + + '
' + + '' + + '' + + '' + + '' + + '
' + + '' + + '
' + + '
' + + '' + + '' + + '' + '
'; 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 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); })();