chore: archive design handoff bundle for toolbar refresh

Stores SethMux_4-24-26.zip + extracted design_handoff_sethmux_toolbar/
so the spec, mockups, and reference jsx components stay in-repo for
future reference. Not served in production — the only file that ships
is static/toolbar.js, which already matches the design's toolbar.js
byte-for-byte.
This commit is contained in:
Mortdecai
2026-04-24 19:51:48 -04:00
parent 2b375b0206
commit b8a7810bfd
8 changed files with 1340 additions and 0 deletions
Binary file not shown.
@@ -0,0 +1,145 @@
# Handoff: sethmux mobile toolbar — Google Workspace dark refresh + compose bar
## Overview
This handoff covers two related changes to the sethmux web terminal's mobile UI (`static/toolbar.js`):
1. **Theme refresh** — re-skin the existing 2-row mobile button bar to match the Google Workspace dark vocabulary (palette, typography, hairline borders, hover behavior) while keeping sethmux's orange `#D35400` as the accent identity.
2. **Mobile autocorrect workaround** — add an opt-in compose bar (third row, toggled by a "Type" button) that gives Gboard / iOS keyboards a real `<input>` to operate on. Typed text is flushed to the terminal stdin on Enter / Send so swipe, autocorrect, and predictions all work despite xterm.js's per-keystroke input model.
## About the Design Files
The files in this bundle are **design references**`toolbar.js` is the single source file to ship; the `preview*.html` files are visual mockups demonstrating intended look and behavior in a Chrome window and an Android phone frame, and the `*.jsx` files are throwaway frame components used only by the previews.
The task is to drop the new `toolbar.js` into the existing sethmux project at `static/toolbar.js`, replacing the current file. No build step required; no other static files change. The previews are not meant to be served in production.
## Fidelity
**High-fidelity.** All colors, type sizes, spacings, radii, and behaviors are final. Implement to spec.
## What changed in toolbar.js
### Visual system (Google Workspace dark)
| Token | Hex | Usage |
|------------------|-----------|-----------------------------------------------|
| toolbar bg | `#202124` | bar background |
| button surface | `#303134` | button face at rest |
| border | `#3c4043` | 1px hairlines, group dividers |
| primary text | `#e8eaed` | button labels |
| secondary text | `#9aa0a6` | mono-class buttons at rest |
| tertiary text | `#5f6368` | placeholder, disabled |
| accent (sethmux) | `#D35400` | active/toggled fill, send button, focus ring |
| accent at rest | `#f0a36b` | text color of `.hi` (orange-tinted) buttons |
| accent bg-1 | `#2a1f15` | `.hi` button background at rest |
| accent bg-2 | `#3a2a1a` | hover wash (subtle orange tint) |
| accent border | `#5a3a22` | `.hi` border at rest |
| success at rest | `#81c995` | Save button text |
| success filled | `#1e8e3e` | Save button after confirm |
### Typography
- Primary: `Roboto, 'Helvetica Neue', Arial, sans-serif`, 12px / 500, letter-spacing 0.1px
- Mono (chord and arrow keys, terminal input): `'Roboto Mono', 'SF Mono', ui-monospace, Menlo, Consolas, monospace`, 14px / 400 in the compose input, 12px / 400 on `.mono` buttons
### Geometry
- Bar padding: `6px 8px 7px`
- Button: `height: 32px`, `min-width: 40px`, `padding: 0 10px`, `border-radius: 4px`, `border: 1px solid #3c4043`
- Narrow-phone breakpoint at `max-width: 380px`: button `height: 30px`, `min-width: 34px`, `padding: 0 7px`, `font-size: 11.5px`
- Compose input: `height: 36px`, `padding: 0 10px`, `border-radius: 4px`, orange caret + orange focus border `#D35400`
- Send button: `height: 36px`, `min-width: 54px`, filled `#D35400` on text `#0a0a0a`
- Newline button (`↵`): same height as Send, `min-width: 38px`, neutral surface (`#303134` / `#3c4043`)
- Group dividers (`.sep`): 1px wide × 20px tall, `#3c4043`, 4px horizontal margin (2px on narrow phones)
- Bar shadow: `0 -1px 0 rgba(0,0,0,.4), 0 -8px 24px rgba(0,0,0,.35)`
- Transitions: `background .15s ease, border-color .15s ease, color .15s ease` (no bounces)
### Button states
- Default: `#303134` bg, `#3c4043` border, `#e8eaed` text
- Hover: `#3a2a1a` bg, `#fff` text
- Active (mouse-down): full orange fill `#D35400` on `#0a0a0a`
- `.hi` (accent at rest): orange-tinted bg `#2a1f15`, border `#5a3a22`, text `#f0a36b`. Hover deepens both
- `.on` (toggled, e.g. selection mode active, Type active): full `#D35400` fill, hover lightens to `#e26416`
- `.mono` (chord keys): mono font, `#9aa0a6` text → `#e8eaed` on hover
- `.grn` (Save): `#81c995` text at rest; `.grn.on` (post-confirm) fills `#1e8e3e` and shows "✓ Saved" for 1500ms
### Layout / structure
The bar is a single `position: fixed; bottom: 0` element with `flex-direction: column`.
**Row 1** (always visible):
```
[+ Tab] [Next] [Prev] | [^C] [^D] [Clr] | [Esc] [Tab] [▲] [▼]
```
- `+ Tab` is `.hi`, the rest neutral, `^C ^D Esc Tab ▲ ▼` are `.mono`.
**Row 2** (always visible):
```
[Sel] [Paste] [Zoom] [Save] | [V.Spl] [H.Spl] [Pane] [Kill] | [Type]
```
- `Sel`, `Paste`, `Type` are `.hi`. `Save` is `.grn`. The `Type` button is the new entry point for the compose bar.
**Row 3 — compose** (only visible when `#mb` has class `composing`):
```
[ input field ] [↵] [Send]
```
- The input is full-width-flex, monospace, autocorrect on. `↵` and `Send` both flush.
### Interactions / behavior
**Send to stdin (`send(k)`):** unchanged. Tries `term._core.coreService.triggerDataEvent(k)` first, falls back to `term._core._onData.fire(k)`, then `term.input(k)`. Always re-focuses the terminal afterward.
**Selection mode (`Sel`):** unchanged. Toggles `body.selmode` which adds `pointer-events: none` to `.xterm-screen` and forces text selection. While in selmode the body also gets `filter: brightness(.92)` for a subtle visual cue. Sending any key auto-exits selmode.
**Paste:** unchanged. Reads `navigator.clipboard.readText()` and feeds the result through `send()`. Alerts if HTTPS clipboard is unavailable.
**Save:** unchanged behavior — sends `\x01S` (`Ctrl-A S`, the tmux capture binding). Adds the `.on` class and label `✓ Saved` for 1500ms, then reverts.
**Compose / Type (new):**
- Tapping `Type` toggles the `composing` class on `#mb`.
- On open, `#mb-compose` gets focused via `setTimeout(...,0)` so iOS/Android keyboards open reliably.
- Input attributes: `autocomplete="on" autocorrect="on" autocapitalize="sentences" spellcheck="true" enterkeyhint="send" inputmode="text"`. These are required — the whole point is to let the OS keyboard's correction layer mutate the input value, which xterm.js's hidden textarea cannot do.
- Pressing `Enter` (or tapping `Send` or `↵`) calls `flushCompose(true)`: reads the input value, clears it, sends the value as a string to stdin, then sends `\r`. Re-focuses the input so the user can keep typing.
- Other toolbar buttons remain active while composing. If a chord/arrow/etc. button is tapped with non-empty input text, the typed text is flushed first (preserving order), then the chord is sent. **The compose bar stays open**; the user is mid-thought.
**Layout reflow (`relayout()`):** The terminal pane (`.xterm`) is sized via `height: calc(100vh - {barHeight + 4}px)` whenever the bar's height changes. This runs on:
- DOM mutations (existing `MutationObserver`)
- Window resize
- Toggling the `composing` class (called from `toggleType()`)
The previous implementation hard-coded `calc(100vh - 92px)`; the new code measures `bar.offsetHeight` so opening the third row reflows correctly without overlap.
**Helper textarea hardening:** unchanged. Still sets `autocomplete=off`, `autocorrect=off`, `autocapitalize=off`, `spellcheck=false` on `.xterm-helper-textarea` once it appears. This is the standard xterm.js mitigation and is still useful — it prevents Gboard from trying to munge the per-keystroke stream when the user happens to type *outside* the compose bar.
### Mobile breakpoint
The toolbar shows at `@media (max-width: 900px)`. Unchanged. Above that, sethmux assumes a real keyboard and the bar stays hidden.
## Why the compose bar is necessary
xterm.js reads from a hidden `<textarea>` and forwards `keydown` / `keypress` events plus IME `compositionstart` / `compositionend` to the terminal's data event one keystroke at a time. Mobile keyboards' autocorrect, swipe input, and word predictions don't fire per-key events — they replace the textarea's `.value` in bulk via `input` events with `inputType: "insertReplacementText"` or `"insertCompositionText"`. xterm.js's input handler discards those, so corrections silently fail. The compose bar sidesteps this by giving the OS keyboard a real text field, then sending the assembled string in one shot.
## Files in this bundle
| File | Purpose |
|----------------------------|-------------------------------------------------------------------|
| `toolbar.js` | **Ship this.** Drop into `static/toolbar.js`. |
| `preview.html` | Minimal mock terminal + the live `toolbar.js` for in-browser test |
| `preview-desktop.html` | Chrome window mock showing the full sethmux UI with tmux tabs |
| `preview-mobile.html` | Two Android frames: collapsed state + compose-open state |
| `browser-window.jsx` | Used only by `preview-desktop.html` |
| `android-frame.jsx` | Used only by `preview-mobile.html` |
## Acceptance checklist
- [ ] Bar appears only at viewport widths ≤ 900px.
- [ ] All 20 original buttons present and bound to the same key sequences.
- [ ] `Type` button toggles a third row with input + `↵` + `Send`.
- [ ] Pressing Enter inside the input sends the typed text plus `\r` and clears the field.
- [ ] Tapping any other toolbar button while composing flushes the typed text first, then sends the button's key. Compose bar stays open.
- [ ] Terminal pane `height` reflows when the bar grows/shrinks; nothing overlaps.
- [ ] Autocorrect / swipe / predictions work in the compose input on Gboard and iOS.
- [ ] Save button shows `✓ Saved` filled green for 1500ms after tap.
- [ ] Sel button toggles to `Done` (filled orange) and dims the terminal slightly.
- [ ] No console errors; `window._toolbar` guards against double-init.
@@ -0,0 +1,214 @@
// Android.jsx — Simplified Android (Material 3) device frame
// Status bar + top app bar + content + gesture nav + keyboard.
// Based on Figma M3 spec. No dependencies, no image assets.
const MD_C = {
surface: '#f4fbf8',
surfaceVariant: '#dae5e1',
inverseOnSurface: '#ecf2ef',
secondaryContainer: '#cde8e1',
primaryFixedDim: '#83d5c6',
onSurface: '#171d1b',
onSurfaceVar: '#49454f',
onPrimaryContainer: '#00201c',
primary: '#006a60',
frameBorder: 'rgba(116,119,117,0.5)',
};
// ─────────────────────────────────────────────────────────────
// Status bar (time left, wifi/cell/battery right)
// ─────────────────────────────────────────────────────────────
function AndroidStatusBar({ dark = false }) {
const c = dark ? '#fff' : MD_C.onSurface;
return (
<div style={{
height: 40, display: 'flex', alignItems: 'center',
justifyContent: 'space-between', padding: '0 16px',
position: 'relative',
fontFamily: 'Roboto, system-ui, sans-serif',
}}>
{/* time left */}
<div style={{ width: 128, display: 'flex', alignItems: 'center', gap: 8 }}>
<span style={{ fontSize: 14, fontWeight: 400, letterSpacing: 0.25, lineHeight: '20px', color: c }}>9:30</span>
</div>
{/* camera punch-hole (center) */}
<div style={{
position: 'absolute', left: '50%', top: 8, transform: 'translateX(-50%)',
width: 24, height: 24, borderRadius: 100, background: '#2e2e2e',
}} />
{/* status icons right */}
<div style={{ display: 'flex', alignItems: 'center' }}>
<div style={{ display: 'flex', paddingRight: 2 }}>
<svg width="16" height="16" viewBox="0 0 16 16" style={{ marginRight: -2 }}>
<path d="M8 13.3L.67 5.97a10.37 10.37 0 0114.66 0L8 13.3z" fill={c}/>
</svg>
<svg width="16" height="16" viewBox="0 0 16 16" style={{ marginRight: -2 }}>
<path d="M14.67 14.67V1.33L1.33 14.67h13.34z" fill={c}/>
</svg>
</div>
<svg width="16" height="16" viewBox="0 0 16 16">
<rect x="3.75" y="2" width="8.5" height="13" rx="1.5" fill={c}/>
<rect x="5.5" y="0.9" width="5" height="2" rx="0.5" fill={c}/>
</svg>
</div>
</div>
);
}
// ─────────────────────────────────────────────────────────────
// Top app bar (Material 3 small/medium)
// ─────────────────────────────────────────────────────────────
function AndroidAppBar({ title = 'Title', large = false }) {
const iconDot = (
<div style={{
width: 48, height: 48, display: 'flex', alignItems: 'center', justifyContent: 'center',
}}>
<div style={{ width: 22, height: 22, borderRadius: '50%', background: MD_C.onSurfaceVar, opacity: 0.3 }} />
</div>
);
return (
<div style={{ background: MD_C.surface, padding: '4px 4px 0' }}>
<div style={{ height: 56, display: 'flex', alignItems: 'center', gap: 4 }}>
{iconDot}
{!large && (
<span style={{
flex: 1, fontSize: 22, fontWeight: 400, color: MD_C.onSurface,
fontFamily: 'Roboto, system-ui, sans-serif',
}}>{title}</span>
)}
{large && <div style={{ flex: 1 }} />}
{iconDot}
</div>
{large && (
<div style={{
padding: '16px 16px 20px',
fontSize: 28, fontWeight: 400, color: MD_C.onSurface,
fontFamily: 'Roboto, system-ui, sans-serif',
}}>{title}</div>
)}
</div>
);
}
// ─────────────────────────────────────────────────────────────
// List item (Material 3)
// ─────────────────────────────────────────────────────────────
function AndroidListItem({ headline, supporting, leading }) {
return (
<div style={{
display: 'flex', alignItems: 'center', gap: 16,
padding: '12px 16px', minHeight: 56, boxSizing: 'border-box',
fontFamily: 'Roboto, system-ui, sans-serif',
}}>
{leading && (
<div style={{
width: 40, height: 40, borderRadius: '50%',
background: MD_C.primary, color: '#fff',
display: 'flex', alignItems: 'center', justifyContent: 'center',
fontSize: 18, fontWeight: 500, flexShrink: 0,
}}>{leading}</div>
)}
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 16, color: MD_C.onSurface, lineHeight: '24px' }}>{headline}</div>
{supporting && (
<div style={{ fontSize: 14, color: MD_C.onSurfaceVar, lineHeight: '20px' }}>{supporting}</div>
)}
</div>
</div>
);
}
// ─────────────────────────────────────────────────────────────
// Gesture nav bar (pill)
// ─────────────────────────────────────────────────────────────
function AndroidNavBar({ dark = false }) {
return (
<div style={{
height: 24, display: 'flex', alignItems: 'center', justifyContent: 'center',
}}>
<div style={{
width: 108, height: 4, borderRadius: 2,
background: dark ? '#fff' : MD_C.onSurface, opacity: 0.4,
}} />
</div>
);
}
// ─────────────────────────────────────────────────────────────
// Device frame — wraps everything
// ─────────────────────────────────────────────────────────────
function AndroidDevice({
children, width = 412, height = 892, dark = false,
title, large = false, keyboard = false,
}) {
return (
<div style={{
width, height, borderRadius: 18, overflow: 'hidden',
background: dark ? '#1d1b20' : MD_C.surface,
border: `8px solid ${MD_C.frameBorder}`,
boxShadow: '0 30px 80px rgba(0,0,0,0.25)',
display: 'flex', flexDirection: 'column', boxSizing: 'border-box',
}}>
<AndroidStatusBar dark={dark} />
{title !== undefined && <AndroidAppBar title={title} large={large} />}
<div style={{ flex: 1, overflow: 'auto' }}>
{children}
</div>
{keyboard && <AndroidKeyboard />}
<AndroidNavBar dark={dark} />
</div>
);
}
// ─────────────────────────────────────────────────────────────
// Keyboard — Gboard (Material 3)
// ─────────────────────────────────────────────────────────────
function AndroidKeyboard() {
let _k = 0;
const key = (l, { flex = 1, bg = MD_C.surface, r = 6, minW, fs = 21 } = {}) => (
<div key={_k++} style={{
height: 46, borderRadius: r, flex, minWidth: minW,
background: bg, display: 'flex', alignItems: 'center', justifyContent: 'center',
fontFamily: 'Roboto, system-ui', fontSize: fs,
color: MD_C.onPrimaryContainer,
}}>{l}</div>
);
const row = (keys, style = {}) => (
<div style={{ display: 'flex', gap: 6, justifyContent: 'center', ...style }}>
{keys.map(l => key(l))}
</div>
);
return (
<div style={{
background: MD_C.inverseOnSurface, padding: '0 8px 8px',
display: 'flex', flexDirection: 'column', gap: 4,
}}>
{/* navbar spacer (icons omitted) */}
<div style={{ height: 44 }} />
{/* key rows */}
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
{row(['q','w','e','r','t','y','u','i','o','p'])}
{row(['a','s','d','f','g','h','j','k','l'], { padding: '0 20px' })}
<div style={{ display: 'flex', gap: 6 }}>
{key('', { bg: MD_C.surfaceVariant })}
<div style={{ display: 'flex', gap: 6, flex: 7, minWidth: 274 }}>
{['z','x','c','v','b','n','m'].map(l => key(l))}
</div>
{key('', { bg: MD_C.surfaceVariant })}
</div>
<div style={{ display: 'flex', gap: 6 }}>
{key('?123', { bg: MD_C.secondaryContainer, r: 100, minW: 58, fs: 14 })}
{key(',', { bg: MD_C.surfaceVariant })}
{key('', { flex: 3, minW: 154 })}
{key('.', { bg: MD_C.surfaceVariant })}
{key('', { bg: MD_C.primaryFixedDim, r: 100, minW: 58 })}
</div>
</div>
</div>
);
}
Object.assign(window, {
AndroidDevice, AndroidStatusBar, AndroidAppBar, AndroidListItem, AndroidNavBar, AndroidKeyboard,
});
@@ -0,0 +1,114 @@
// Chrome.jsx — Simplified Chrome browser window (dark theme, macOS)
// No dependencies, no image assets. All inline styles + inline SVG.
const CHROME_C = {
barBg: '#202124',
tabBg: '#35363a',
text: '#e8eaed',
dim: '#9aa0a6',
urlBg: '#282a2d',
};
function ChromeTrafficLights() {
return (
<div style={{ display: 'flex', gap: 8, padding: '0 14px' }}>
<div style={{ width: 12, height: 12, borderRadius: '50%', background: '#ff5f57' }} />
<div style={{ width: 12, height: 12, borderRadius: '50%', background: '#febc2e' }} />
<div style={{ width: 12, height: 12, borderRadius: '50%', background: '#28c840' }} />
</div>
);
}
// Single tab (active has curved scoops)
function ChromeTab({ title = 'New Tab', active = false }) {
const curve = (flip) => (
<svg width="8" height="10" viewBox="0 0 8 10"
style={{ position: 'absolute', bottom: 0, [flip ? 'right' : 'left']: -8, transform: flip ? 'scaleX(-1)' : 'none' }}>
<path d="M0 10C2 9 6 8 8 0V10H0Z" fill={CHROME_C.tabBg}/>
</svg>
);
return (
<div style={{
position: 'relative', height: 34, alignSelf: 'flex-end',
padding: '0 12px', display: 'flex', alignItems: 'center', gap: 8,
background: active ? CHROME_C.tabBg : 'transparent',
borderRadius: '8px 8px 0 0', minWidth: 120, maxWidth: 220,
fontFamily: 'system-ui, sans-serif', fontSize: 12,
color: active ? CHROME_C.text : CHROME_C.dim,
}}>
{active && curve(false)}
{active && curve(true)}
<div style={{ width: 14, height: 14, borderRadius: '50%', background: '#5f6368', flexShrink: 0 }} />
<span style={{ flex: 1, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{title}</span>
</div>
);
}
function ChromeTabBar({ tabs = [{ title: 'New Tab' }], activeIndex = 0 }) {
return (
<div style={{
display: 'flex', alignItems: 'center', height: 44,
background: CHROME_C.barBg, paddingRight: 8,
}}>
<ChromeTrafficLights />
<div style={{ display: 'flex', alignItems: 'flex-end', height: '100%', paddingLeft: 4, flex: 1 }}>
{tabs.map((t, i) => <ChromeTab key={i} title={t.title} active={i === activeIndex} />)}
</div>
</div>
);
}
function ChromeToolbar({ url = 'example.com' }) {
const iconDot = (
<div style={{
width: 28, height: 28, display: 'flex', alignItems: 'center', justifyContent: 'center',
}}>
<div style={{ width: 16, height: 16, borderRadius: '50%', background: CHROME_C.dim, opacity: 0.4 }} />
</div>
);
return (
<div style={{
height: 40, background: CHROME_C.tabBg,
display: 'flex', alignItems: 'center', gap: 4, padding: '0 8px',
}}>
{iconDot}
{/* url bar */}
<div style={{
flex: 1, height: 30, borderRadius: 15, background: CHROME_C.urlBg,
display: 'flex', alignItems: 'center', gap: 8, padding: '0 14px',
margin: '0 6px',
}}>
<div style={{ width: 12, height: 12, borderRadius: '50%', background: CHROME_C.dim, opacity: 0.4 }} />
<span style={{
flex: 1, color: CHROME_C.text, fontSize: 13,
fontFamily: 'system-ui, sans-serif',
}}>{url}</span>
</div>
{iconDot}
</div>
);
}
function ChromeWindow({
tabs = [{ title: 'New Tab' }], activeIndex = 0, url = 'example.com',
width = 900, height = 600, children,
}) {
return (
<div style={{
width, height, borderRadius: 10, overflow: 'hidden',
boxShadow: '0 24px 80px rgba(0,0,0,0.35), 0 0 0 1px rgba(0,0,0,0.1)',
display: 'flex', flexDirection: 'column', background: CHROME_C.tabBg,
}}>
<ChromeTabBar tabs={tabs} activeIndex={activeIndex} />
<ChromeToolbar url={url} />
<div style={{ flex: 1, background: '#fff', overflow: 'auto' }}>
{children}
</div>
</div>
);
}
Object.assign(window, {
ChromeWindow, ChromeTabBar, ChromeToolbar, ChromeTab, ChromeTrafficLights,
});
@@ -0,0 +1,197 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>sethmux — desktop with tabs</title>
<meta name="viewport" content="width=device-width,initial-scale=1" />
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Roboto:wght@400;500&family=Roboto+Mono:wght@400&display=swap" rel="stylesheet">
<style>
*{box-sizing:border-box;margin:0;padding:0}
html,body{height:100%}
body{
background:#0d0d10;
display:flex;align-items:center;justify-content:center;
font:12px/1.4 'Roboto','Helvetica Neue',Arial,sans-serif;
-webkit-font-smoothing:antialiased;
padding:24px;
overflow:auto;
}
/* ── App inside the browser ───────────────────────── */
.app{
background:#1a1a1d;
color:#e8eaed;
height:100%;
display:flex;flex-direction:column;
font:12px/1.4 'Roboto','Helvetica Neue',Arial,sans-serif;
}
/* sethmux own top chrome (matches Google-workspace dark) */
.top{
height:48px;flex-shrink:0;
background:#202124;border-bottom:1px solid #3c4043;
display:flex;align-items:center;padding:0 14px;gap:14px;
}
.brand{
color:#D35400;font-weight:500;font-size:14px;letter-spacing:.2px;
font-family:'Roboto',sans-serif;
}
.tabs{display:flex;gap:0;height:100%;align-items:stretch}
.tab{
display:flex;align-items:center;gap:8px;
padding:0 14px;height:100%;
color:#9aa0a6;font-size:12px;font-weight:500;
border-bottom:2px solid transparent;cursor:default;
}
.tab.active{color:#D35400;border-bottom-color:#D35400}
.tab .num{
font-family:'Roboto Mono',ui-monospace,monospace;font-size:10px;
color:#5f6368;
}
.tab.active .num{color:#D35400;opacity:.8}
.tab .dot{
width:6px;height:6px;border-radius:50%;background:#3c4043;
}
.tab.active .dot{background:#D35400}
.tab.alert .dot{background:#fdd663}
.spacer{flex:1}
.meta{
color:#9aa0a6;font-family:'Roboto Mono',ui-monospace,monospace;font-size:11px;
}
.meta b{color:#e8eaed;font-weight:400}
/* terminal */
.xterm{
flex:1;
padding:12px 14px;
overflow:hidden;
font-family:'Roboto Mono',ui-monospace,Menlo,Consolas,monospace;
font-size:13px;line-height:1.5;
color:#cfd2d6;background:#1a1a1d;
}
.ps1{color:#81c995}
.path{color:#8ab4f8}
.git{color:#fdd663}
.cmd{color:#e8eaed}
.dim{color:#5f6368}
.err{color:#f28b82}
.ok{color:#81c995}
.cursor{
display:inline-block;width:8px;height:15px;background:#e8eaed;
vertical-align:-2px;margin-left:1px;animation:blink 1.1s steps(1) infinite;
}
@keyframes blink{50%{opacity:0}}
/* status bar (above toolbar on desktop) */
.status{
height:24px;flex-shrink:0;
background:#202124;border-top:1px solid #3c4043;
display:flex;align-items:center;justify-content:space-between;
padding:0 14px;color:#9aa0a6;
font-family:'Roboto Mono',ui-monospace,monospace;font-size:11px;
}
.status .left,.status .right{display:flex;gap:14px;align-items:center}
.status b{color:#e8eaed;font-weight:400}
.status .ok{color:#81c995}
/* desktop hides the mobile toolbar — show it anyway in this preview
so reviewers can see what mobile users get */
#mb{display:flex !important;position:relative !important;
box-shadow:0 -1px 0 #3c4043 !important;flex-shrink:0;}
/* Chrome wrapper sets its own background */
</style>
</head>
<body>
<div id="root"></div>
<script src="https://unpkg.com/react@18.3.1/umd/react.development.js" integrity="sha384-hD6/rw4ppMLGNu3tX5cjIb+uRZ7UkRJ6BPkLpg4hAu/6onKUg4lLsHAs9EBPT82L" crossorigin="anonymous"></script>
<script src="https://unpkg.com/react-dom@18.3.1/umd/react-dom.development.js" integrity="sha384-u6aeetuaXnQ38mYT8rp6sbXaQe3NL9t+IBXmnYxwkUI2Hw4bsp2Wvmx4yRQF1uAm" crossorigin="anonymous"></script>
<script src="https://unpkg.com/@babel/standalone@7.29.0/babel.min.js" integrity="sha384-m08KidiNqLdpJqLq95G/LEi8Qvjl/xUYll3QILypMoQ65QorJ9Lvtp2RXYGBFj1y" crossorigin="anonymous"></script>
<script type="text/babel" src="browser-window.jsx"></script>
<script type="text/babel">
const { useEffect } = React;
function SethmuxApp() {
return (
<div className="app">
<div className="top">
<div className="brand">sethmux</div>
<div className="tabs">
<div className="tab"><span className="num">1</span><span className="dot"/>code</div>
<div className="tab active"><span className="num">2</span><span className="dot"/>git</div>
<div className="tab alert"><span className="num">3</span><span className="dot"/>run</div>
<div className="tab"><span className="num">4</span><span className="dot"/>logs</div>
<div className="tab"><span className="num">5</span><span className="dot"/>notes</div>
</div>
<div className="spacer"/>
<div className="meta">tmux <b>sethmux</b> · 1 client · 168×42</div>
</div>
<div className="xterm">
<div><span className="ps1">seth@mux</span> <span className="path">~/sethmux</span> <span className="git">(main)</span> <span className="dim">$</span> <span className="cmd">git status</span></div>
<div>On branch <span className="ok">main</span></div>
<div>Your branch is up to date with <span className="path">'origin/main'</span>.</div>
<div>&nbsp;</div>
<div>Changes not staged for commit:</div>
<div className="dim"> (use "git add &lt;file&gt;..." to update what will be committed)</div>
<div>&nbsp;</div>
<div> <span className="err">modified:</span> static/toolbar.js</div>
<div> <span className="err">modified:</span> README.md</div>
<div>&nbsp;</div>
<div>Untracked files:</div>
<div> <span className="err">static/compose-bar.js</span></div>
<div>&nbsp;</div>
<div><span className="ps1">seth@mux</span> <span className="path">~/sethmux</span> <span className="git">(main)</span> <span className="dim">$</span> <span className="cmd">git diff --stat static/toolbar.js</span></div>
<div> static/toolbar.js | <span className="ok">+138</span> <span className="err">-21</span></div>
<div>&nbsp;</div>
<div><span className="ps1">seth@mux</span> <span className="path">~/sethmux</span> <span className="git">(main)</span> <span className="dim">$</span> <span className="cursor"/></div>
</div>
<div className="status">
<div className="left">
<span><b>code</b> · <b>git</b> · run · logs · notes</span>
<span>pane <b>0</b>/1</span>
</div>
<div className="right">
<span className="ok"> connected</span>
<span><b>14:02</b></span>
</div>
</div>
{/* mobile toolbar mounts at body level via toolbar.js */}
</div>
);
}
function App() {
useEffect(() => {
// Load the real toolbar after render so it can find a place to mount.
if (window._toolbar) return;
const s = document.createElement('script');
s.src = 'toolbar.js';
document.body.appendChild(s);
}, []);
const tabs = [
{ title: 'sethmux — git' },
{ title: 'GitHub · sethmux' },
{ title: 'PR #42 · review' },
];
return (
<div style={{display:'flex',alignItems:'center',justifyContent:'center'}}>
<ChromeWindow tabs={tabs} activeIndex={0} url="mux.seth.dev" width={1180} height={760}>
<SethmuxApp/>
</ChromeWindow>
</div>
);
}
ReactDOM.createRoot(document.getElementById('root')).render(<App/>);
</script>
</body>
</html>
@@ -0,0 +1,269 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>sethmux — mobile</title>
<meta name="viewport" content="width=device-width,initial-scale=1" />
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Roboto:wght@400;500&family=Roboto+Mono:wght@400&display=swap" rel="stylesheet">
<style>
*{box-sizing:border-box;margin:0;padding:0}
html,body{height:100%}
body{
background:#0d0d10;
display:flex;align-items:center;justify-content:center;
font-family:'Roboto',sans-serif;
padding:24px;
overflow:auto;
}
/* The phone holds the sethmux app inline (not the floating toolbar).
We render a self-contained mobile UI here that mirrors the toolbar
visuals exactly, plus the compose row open and Gboard below. */
.phone-app{
display:flex;flex-direction:column;height:100%;
background:#1a1a1d;color:#e8eaed;
font-family:'Roboto',sans-serif;
}
.phone-top{
height:44px;flex-shrink:0;
background:#202124;border-bottom:1px solid #3c4043;
display:flex;align-items:center;justify-content:space-between;
padding:0 14px;
}
.phone-top .brand{color:#D35400;font-weight:500;font-size:13px}
.phone-top .tabs{display:flex;gap:10px;color:#9aa0a6;
font-family:'Roboto Mono',ui-monospace,monospace;font-size:11px}
.phone-top .tabs b{color:#D35400;font-weight:500}
.phone-xterm{
flex:1;padding:8px 10px;overflow:hidden;background:#1a1a1d;
font-family:'Roboto Mono',ui-monospace,Menlo,Consolas,monospace;
font-size:12px;line-height:1.5;color:#cfd2d6;
}
.phone-xterm .ps1{color:#81c995}
.phone-xterm .path{color:#8ab4f8}
.phone-xterm .git{color:#fdd663}
.phone-xterm .cmd{color:#e8eaed}
.phone-xterm .dim{color:#5f6368}
.phone-xterm .ok{color:#81c995}
.phone-xterm .err{color:#f28b82}
.phone-xterm .cursor{
display:inline-block;width:7px;height:13px;background:#e8eaed;
vertical-align:-2px;margin-left:1px;animation:blink 1.1s steps(1) infinite;
}
@keyframes blink{50%{opacity:0}}
/* ── inline toolbar (same visuals as toolbar.js, but mounted in-tree) ── */
.tb{
flex-shrink:0;
background:#202124;border-top:1px solid #3c4043;
padding:6px 8px 7px;
box-shadow:0 -1px 0 rgba(0,0,0,.4),0 -8px 24px rgba(0,0,0,.35);
display:flex;flex-direction:column;
font-family:'Roboto',sans-serif;
}
.tb .row{display:flex;gap:4px;justify-content:center;align-items:center;width:100%}
.tb .row + .row{margin-top:4px}
.tb button{
background:#303134;color:#e8eaed;border:1px solid #3c4043;
border-radius:4px;padding:0 8px;height:32px;min-width:38px;
font:500 12px/1 'Roboto',sans-serif;letter-spacing:.1px;
display:inline-flex;align-items:center;justify-content:center;
}
.tb .mono{
font-family:'Roboto Mono',ui-monospace,Menlo,Consolas,monospace;
font-weight:400;color:#9aa0a6;
}
.tb .hi{color:#f0a36b;border-color:#5a3a22;background:#2a1f15}
.tb .on{background:#D35400;border-color:#D35400;color:#0a0a0a}
.tb .grn{color:#81c995}
.tb .sep{width:1px;height:20px;background:#3c4043;margin:0 3px;flex-shrink:0}
.tb .compose{display:flex;width:100%;gap:4px;align-items:center;margin-top:4px}
.tb input{
flex:1;min-width:0;height:36px;padding:0 10px;
background:#303134;color:#e8eaed;
border:1px solid #D35400;border-radius:4px;
font:400 14px/1 'Roboto Mono',ui-monospace,Menlo,Consolas,monospace;
outline:none;-webkit-appearance:none;appearance:none;
caret-color:#D35400;
}
.tb .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;
}
.tb .send.nl{
background:#303134;border-color:#3c4043;color:#9aa0a6;
min-width:38px;padding:0 8px;
}
/* annotation captions next to each phone */
.stack{display:flex;gap:32px;align-items:flex-start}
.col{display:flex;flex-direction:column;align-items:center;gap:14px}
.cap{
color:#9aa0a6;font-size:12px;text-align:center;max-width:340px;
font-family:'Roboto',sans-serif;
}
.cap b{color:#e8eaed;font-weight:500;display:block;margin-bottom:4px;font-size:13px}
</style>
</head>
<body>
<div id="root"></div>
<script src="https://unpkg.com/react@18.3.1/umd/react.development.js" integrity="sha384-hD6/rw4ppMLGNu3tX5cjIb+uRZ7UkRJ6BPkLpg4hAu/6onKUg4lLsHAs9EBPT82L" crossorigin="anonymous"></script>
<script src="https://unpkg.com/react-dom@18.3.1/umd/react-dom.development.js" integrity="sha384-u6aeetuaXnQ38mYT8rp6sbXaQe3NL9t+IBXmnYxwkUI2Hw4bsp2Wvmx4yRQF1uAm" crossorigin="anonymous"></script>
<script src="https://unpkg.com/@babel/standalone@7.29.0/babel.min.js" integrity="sha384-m08KidiNqLdpJqLq95G/LEi8Qvjl/xUYll3QILypMoQ65QorJ9Lvtp2RXYGBFj1y" crossorigin="anonymous"></script>
<script type="text/babel" src="android-frame.jsx"></script>
<script type="text/babel">
function PhoneTop({tab='code', n=1, total=4}) {
return (
<div className="phone-top">
<div className="brand">sethmux</div>
<div className="tabs"><b>{tab}</b> · {n}/{total}</div>
</div>
);
}
function PhoneXterm({collapsed}) {
return (
<div className="phone-xterm">
<div><span className="ps1">seth@mux</span> <span className="path">~</span> <span className="dim">$</span> <span className="cmd">tmux ls</span></div>
<div>sethmux: 4 windows</div>
<div className="dim"> code git run logs</div>
<div>&nbsp;</div>
<div><span className="ps1">seth@mux</span> <span className="path">~</span> <span className="dim">$</span> <span className="cmd">make build</span></div>
<div className="ok"> ok in 2.31s</div>
{!collapsed && <>
<div>&nbsp;</div>
<div><span className="ps1">seth@mux</span> <span className="path">~</span> <span className="dim">$</span> <span className="cmd">tail -f logs/run.log</span></div>
<div className="dim">[14:02:11] http 200 /api/notify 4ms</div>
<div className="dim">[14:02:14] http 200 /api/notify 3ms</div>
</>}
<div>&nbsp;</div>
<div><span className="ps1">seth@mux</span> <span className="path">~</span> <span className="dim">$</span> <span className="cursor"/></div>
</div>
);
}
function ToolbarCollapsed() {
return (
<div className="tb">
<div className="row">
<button className="hi">+ Tab</button>
<button>Next</button>
<button>Prev</button>
<div className="sep"/>
<button className="mono">^C</button>
<button className="mono">^D</button>
<button>Clr</button>
<div className="sep"/>
<button className="mono">Esc</button>
<button className="mono">Tab</button>
<button className="mono"></button>
<button className="mono"></button>
</div>
<div className="row">
<button className="hi">Sel</button>
<button className="hi">Paste</button>
<button>Zoom</button>
<button className="grn">Save</button>
<div className="sep"/>
<button>V.Spl</button>
<button>H.Spl</button>
<button>Pane</button>
<button>Kill</button>
<div className="sep"/>
<button className="hi">Type</button>
</div>
</div>
);
}
function ToolbarComposing() {
return (
<div className="tb">
<div className="row">
<button className="hi">+ Tab</button>
<button>Next</button>
<button>Prev</button>
<div className="sep"/>
<button className="mono">^C</button>
<button className="mono">^D</button>
<button>Clr</button>
<div className="sep"/>
<button className="mono">Esc</button>
<button className="mono">Tab</button>
<button className="mono"></button>
<button className="mono"></button>
</div>
<div className="row">
<button className="hi">Sel</button>
<button className="hi">Paste</button>
<button>Zoom</button>
<button className="grn">Save</button>
<div className="sep"/>
<button>V.Spl</button>
<button>H.Spl</button>
<button>Pane</button>
<button>Kill</button>
<div className="sep"/>
<button className="on">Type</button>
</div>
<div className="compose">
<input defaultValue="git commit -m &quot;wire up compose bar&quot;" />
<button className="send nl"></button>
<button className="send">Send</button>
</div>
</div>
);
}
function PhoneCollapsed() {
return (
<AndroidDevice width={360} height={720} dark>
<div className="phone-app">
<PhoneTop tab="code" n={1} total={4}/>
<PhoneXterm/>
<ToolbarCollapsed/>
</div>
</AndroidDevice>
);
}
function PhoneComposing() {
return (
<AndroidDevice width={360} height={720} dark>
<div className="phone-app">
<PhoneTop tab="git" n={2} total={4}/>
<PhoneXterm collapsed/>
<ToolbarComposing/>
</div>
</AndroidDevice>
);
}
function App() {
return (
<div className="stack">
<div className="col">
<PhoneCollapsed/>
<div className="cap"><b>Default 2 rows</b>All chord keys, tabs, splits, and Save in reach. Tap <span style={{color:'#f0a36b'}}>Type</span> to open compose.</div>
</div>
<div className="col">
<PhoneComposing/>
<div className="cap"><b>Compose open 3 rows</b>Real input field with Gboard autocorrect / swipe / predictions. or Send flushes to stdin + a newline. Other keys still work mid-typing.</div>
</div>
</div>
);
}
ReactDOM.createRoot(document.getElementById('root')).render(<App/>);
</script>
</body>
</html>
@@ -0,0 +1,96 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>sethmux — toolbar preview</title>
<meta name="viewport" content="width=device-width,initial-scale=1" />
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Roboto:wght@400;500&family=Roboto+Mono:wght@400&display=swap" rel="stylesheet">
<style>
:root{
--bg:#1a1a1d;
--bg-toolbar:#202124;
--border:#3c4043;
--text:#e8eaed;
--text-2:#9aa0a6;
--accent:#D35400;
}
*{box-sizing:border-box;margin:0;padding:0}
html,body{height:100%}
body{
background:var(--bg);
color:var(--text);
font:12px/1.4 'Roboto','Helvetica Neue',Arial,sans-serif;
-webkit-font-smoothing:antialiased;
overflow:hidden;
}
/* Mock chrome strip so the preview reads like a phone-shell view */
.chrome{
height:44px;
background:var(--bg-toolbar);
border-bottom:1px solid var(--border);
display:flex;align-items:center;justify-content:space-between;
padding:0 14px;
color:var(--text-2);
}
.chrome .brand{
color:var(--accent);
font-weight:500;letter-spacing:.2px;font-size:13px;
}
.chrome .meta{
font-family:'Roboto Mono',ui-monospace,monospace;
font-size:11px;color:var(--text-2);
}
.chrome .meta b{color:var(--text);font-weight:400}
/* Mock xterm pane */
.xterm{
height:calc(100vh - 44px - 92px);
transition:height .15s ease;
padding:10px 12px;
overflow:hidden;
font-family:'Roboto Mono',ui-monospace,Menlo,Consolas,monospace;
font-size:13px;line-height:1.45;
color:#cfd2d6;
}
.xterm .ps1{color:#81c995}
.xterm .path{color:#8ab4f8}
.xterm .git{color:#fdd663}
.xterm .cmd{color:#e8eaed}
.xterm .dim{color:#5f6368}
.xterm .cursor{
display:inline-block;width:8px;height:15px;
background:#e8eaed;vertical-align:-2px;margin-left:1px;
animation:blink 1.1s steps(1) infinite;
}
@keyframes blink{50%{opacity:0}}
.xterm-screen{height:100%}
</style>
</head>
<body>
<div class="chrome">
<div class="brand">sethmux</div>
<div class="meta"><b>code</b> · <b>1</b>/4 · 80×24</div>
</div>
<div class="xterm">
<div class="xterm-screen">
<div><span class="ps1">seth@mux</span> <span class="path">~/sethmux</span> <span class="git">(main)</span> <span class="dim">$</span> <span class="cmd">tmux ls</span></div>
<div>sethmux: 4 windows (created Thu Apr 24 09:12:03 2026) (attached)</div>
<div class="dim"> code git run logs</div>
<div>&nbsp;</div>
<div><span class="ps1">seth@mux</span> <span class="path">~/sethmux</span> <span class="git">(main)</span> <span class="dim">$</span> <span class="cmd">make build &amp;&amp; sethmux-notify "OK"</span></div>
<div class="dim">→ built in 2.31s</div>
<div>&nbsp;</div>
<div><span class="ps1">seth@mux</span> <span class="path">~/sethmux</span> <span class="git">(main)</span> <span class="dim">$</span> <span class="cursor"></span></div>
</div>
</div>
<!-- Force the toolbar visible in preview at any width -->
<style>#mb{display:flex !important}</style>
<script src="toolbar.js"></script>
</body>
</html>
@@ -0,0 +1,305 @@
(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);
})();